From 8b379695d7c60871dbfbc8ff6bc90b324100dd0f Mon Sep 17 00:00:00 2001 From: Peter Bagrij Date: Wed, 17 Jan 2024 08:05:30 +0100 Subject: [PATCH] FINERACT-1972 Custom Snapshot Event Triggered by COB --- .../core/boot/FineractProfiles.java | 2 + .../InternalExternalEventsApiResource.java | 80 +++++ .../repository/ExternalEventRepository.java | 3 +- .../service/InternalExternalEventService.java | 147 ++++++++ .../service/validation/ExternalEventDTO.java | 41 +++ ...oanAccountCustomSnapshotBusinessEvent.java | 35 ++ .../module/loan/module-changelog-master.xml | 1 + ...add_loan_account_custom_snapshot_event.xml | 33 ++ .../cob/api/InternalCOBApiResource.java | 3 +- .../InternalLoanAccountLockApiResource.java | 3 +- .../CheckDueInstallmentsBusinessStep.java | 69 ++++ .../api/InstanceModeApiResource.java | 3 +- .../s3/LocalstackS3ClientCustomizer.java | 3 +- .../InternalClientInformationApiResource.java | 3 +- .../InternalLoanInformationApiResource.java | 3 +- .../CheckDueInstallmentsBusinessStepTest.java | 161 +++++++++ ...entConfigurationValidationServiceTest.java | 4 +- .../BaseLoanIntegrationTest.java | 8 +- .../CustomSnapshotEventIntegrationTest.java | 326 ++++++++++++++++++ .../ExternalEventConfigurationHelper.java | 5 + .../integrationtests/common/Utils.java | 3 + .../externalevents/ExternalEventHelper.java | 92 +++++ .../ExternalEventsExtension.java | 88 +++++ .../common/loans/CobHelper.java | 9 +- 24 files changed, 1109 insertions(+), 16 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/api/InternalExternalEventsApiResource.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/InternalExternalEventService.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/validation/ExternalEventDTO.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanAccountCustomSnapshotBusinessEvent.java create mode 100644 fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1014_add_loan_account_custom_snapshot_event.xml create mode 100644 fineract-provider/src/main/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStep.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStepTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventsExtension.java diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/boot/FineractProfiles.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/boot/FineractProfiles.java index 6d0757021..97aca9634 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/boot/FineractProfiles.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/boot/FineractProfiles.java @@ -23,5 +23,7 @@ public final class FineractProfiles { public static final String LIQUIBASE_ONLY = "liquibase-only"; public static final String DIAGNOSTICS = "diagnostics"; + public static final String TEST = "test"; + private FineractProfiles() {} } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/api/InternalExternalEventsApiResource.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/api/InternalExternalEventsApiResource.java new file mode 100644 index 000000000..31b84f99d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/api/InternalExternalEventsApiResource.java @@ -0,0 +1,80 @@ +/** + * 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.infrastructure.event.external.api; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.event.external.service.InternalExternalEventService; +import org.apache.fineract.infrastructure.event.external.service.validation.ExternalEventDTO; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Profile(FineractProfiles.TEST) +@Component +@Path("/v1/internal/externalevents") +@RequiredArgsConstructor +@Slf4j +public class InternalExternalEventsApiResource implements InitializingBean { + + private final InternalExternalEventService internalExternalEventService; + private final DefaultToApiJsonSerializer> jsonSerializer; + + @Override + @SuppressFBWarnings("SLF4J_SIGN_ONLY_FORMAT") + public void afterPropertiesSet() throws Exception { + log.warn("------------------------------------------------------------"); + log.warn(" "); + log.warn("DO NOT USE THIS IN PRODUCTION!"); + log.warn("Internal client services mode is enabled"); + log.warn("DO NOT USE THIS IN PRODUCTION!"); + log.warn(" "); + log.warn("------------------------------------------------------------"); + } + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + public String getAllExternalEvents(@QueryParam("idempotencyKey") final String idempotencyKey, @QueryParam("type") final String type, + @QueryParam("category") final String category, @QueryParam("aggregateRootId") final Long aggregateRootId) { + log.debug("getAllExternalEvents called with params idempotencyKey:{}, type:{}, category:{}, aggregateRootId:{} ", idempotencyKey, + type, category, aggregateRootId); + List allExternalEvents = internalExternalEventService.getAllExternalEvents(idempotencyKey, type, category, + aggregateRootId); + return jsonSerializer.serialize(allExternalEvents); + } + + @DELETE + public void deleteAllExternalEvents() { + log.debug("deleteAllExternalEvents called"); + internalExternalEventService.deleteAllExternalEvents(); + } + +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/repository/ExternalEventRepository.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/repository/ExternalEventRepository.java index 474384d2a..9f7ccbddc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/repository/ExternalEventRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/repository/ExternalEventRepository.java @@ -26,11 +26,12 @@ import org.apache.fineract.infrastructure.event.external.repository.domain.Exter import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventView; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ExternalEventRepository extends JpaRepository { +public interface ExternalEventRepository extends JpaRepository, JpaSpecificationExecutor { List findByStatusOrderById(ExternalEventStatus status, Pageable batchSize); diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/InternalExternalEventService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/InternalExternalEventService.java new file mode 100644 index 000000000..b044b3d33 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/InternalExternalEventService.java @@ -0,0 +1,147 @@ +/** + * 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.infrastructure.event.external.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.avro.BulkMessageItemV1; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; +import org.apache.fineract.infrastructure.event.external.repository.ExternalEventRepository; +import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent; +import org.apache.fineract.infrastructure.event.external.service.validation.ExternalEventDTO; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +@Service +@Profile(FineractProfiles.TEST) +@Slf4j +@AllArgsConstructor +public class InternalExternalEventService { + + private final ExternalEventRepository externalEventRepository; + + public void deleteAllExternalEvents() { + externalEventRepository.deleteAll(); + } + + public List getAllExternalEvents(String idempotencyKey, String type, String category, Long aggregateRootId) { + List> specifications = new ArrayList<>(); + + if (StringUtils.isNotEmpty(idempotencyKey)) { + specifications.add(hasIdempotencyKey(idempotencyKey)); + } + + if (StringUtils.isNotEmpty(type)) { + specifications.add(hasType(type)); + } + + if (StringUtils.isNotEmpty(category)) { + specifications.add(hasCategory(category)); + } + + if (aggregateRootId != null) { + specifications.add(hasAggregateRootId(aggregateRootId)); + } + + Specification reducedSpecification = specifications.stream().reduce(Specification::and) + .orElse((Specification) (root, query, criteriaBuilder) -> null); + List externalEvents = externalEventRepository.findAll(reducedSpecification); + + try { + return convertToReadableFormat(externalEvents); + } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | IllegalAccessException + | JsonProcessingException e) { + throw new RuntimeException("Error while converting external events to readable format", e); + } + } + + private Specification hasIdempotencyKey(String idempotencyKey) { + return (root, query, cb) -> cb.equal(root.get("idempotencyKey"), idempotencyKey); + } + + private Specification hasType(String type) { + return (root, query, cb) -> cb.equal(root.get("type"), type); + } + + private Specification hasCategory(String category) { + return (root, query, cb) -> cb.equal(root.get("category"), category); + } + + private Specification hasAggregateRootId(Long aggregateRootId) { + return (root, query, cb) -> cb.equal(root.get("aggregateRootId"), aggregateRootId); + } + + private List convertToReadableFormat(List externalEvents) throws ClassNotFoundException, + NoSuchMethodException, InvocationTargetException, IllegalAccessException, JsonProcessingException { + List eventMessages = new ArrayList<>(); + for (ExternalEvent externalEvent : externalEvents) { + Class payLoadClass = Class.forName(externalEvent.getSchema()); + ByteBuffer byteBuffer = ByteBuffer.wrap(externalEvent.getData()); + Method method = payLoadClass.getMethod("fromByteBuffer", ByteBuffer.class); + Object payLoad = method.invoke(null, byteBuffer); + if (externalEvent.getType().equalsIgnoreCase("BulkBusinessEvent")) { + Method methodToGetDatas = payLoad.getClass().getMethod("getDatas", (Class) null); + List bulkMessages = (List) methodToGetDatas.invoke(payLoad); + StringBuilder bulkMessagePayload = new StringBuilder(); + for (BulkMessageItemV1 bulkMessage : bulkMessages) { + ExternalEventDTO bulkMessageData = retrieveBulkMessage(bulkMessage, externalEvent); + bulkMessagePayload.append(bulkMessageData); + bulkMessagePayload.append(System.lineSeparator()); + } + eventMessages.add(new ExternalEventDTO(externalEvent.getId(), externalEvent.getType(), externalEvent.getCategory(), + externalEvent.getCreatedAt(), toJsonMap(bulkMessagePayload.toString()), externalEvent.getBusinessDate(), + externalEvent.getSchema(), externalEvent.getAggregateRootId())); + + } else { + eventMessages.add(new ExternalEventDTO(externalEvent.getId(), externalEvent.getType(), externalEvent.getCategory(), + externalEvent.getCreatedAt(), toJsonMap(payLoad.toString()), externalEvent.getBusinessDate(), + externalEvent.getSchema(), externalEvent.getAggregateRootId())); + } + } + + return eventMessages; + } + + private ExternalEventDTO retrieveBulkMessage(BulkMessageItemV1 messageItem, ExternalEvent externalEvent) throws ClassNotFoundException, + InvocationTargetException, IllegalAccessException, NoSuchMethodException, JsonProcessingException { + Class messageBulkMessagePayLoad = Class.forName(messageItem.getDataschema()); + Method methodForPayLoad = messageBulkMessagePayLoad.getMethod("fromByteBuffer", ByteBuffer.class); + Object payLoadBulkItem = methodForPayLoad.invoke(null, messageItem.getData()); + return new ExternalEventDTO((long) messageItem.getId(), messageItem.getType(), messageItem.getCategory(), + externalEvent.getCreatedAt(), toJsonMap(payLoadBulkItem.toString()), externalEvent.getBusinessDate(), + externalEvent.getSchema(), externalEvent.getAggregateRootId()); + } + + private Map toJsonMap(String json) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(json, new TypeReference<>() {}); + } + +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/validation/ExternalEventDTO.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/validation/ExternalEventDTO.java new file mode 100644 index 000000000..dec70373e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/event/external/service/validation/ExternalEventDTO.java @@ -0,0 +1,41 @@ +/** + * 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.infrastructure.event.external.service.validation; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +@Getter +@AllArgsConstructor +@ToString +public class ExternalEventDTO { + + private final Long eventId; + private final String type; + private final String category; + private final OffsetDateTime createdAt; + private final Map payLoad; + private final LocalDate businessDate; + private final String schema; + private final Long aggregateRootId; +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanAccountCustomSnapshotBusinessEvent.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanAccountCustomSnapshotBusinessEvent.java new file mode 100644 index 000000000..628429671 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanAccountCustomSnapshotBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * 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.infrastructure.event.business.domain.loan; + +import org.apache.fineract.portfolio.loanaccount.domain.Loan; + +public class LoanAccountCustomSnapshotBusinessEvent extends LoanBusinessEvent { + + private static final String TYPE = "LoanAccountCustomSnapshotBusinessEvent"; + + public LoanAccountCustomSnapshotBusinessEvent(Loan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml index b08ead78b..4d5fb3f2b 100644 --- a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml @@ -36,4 +36,5 @@ + diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1014_add_loan_account_custom_snapshot_event.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1014_add_loan_account_custom_snapshot_event.xml new file mode 100644 index 000000000..4c1ad18b9 --- /dev/null +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1014_add_loan_account_custom_snapshot_event.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java index 7402955bc..c767a5479 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java @@ -36,6 +36,7 @@ import org.apache.fineract.cob.loan.LoanCOBConstant; import org.apache.fineract.cob.loan.RetrieveLoanIdService; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; @@ -43,7 +44,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -@Profile("test") +@Profile(FineractProfiles.TEST) @Component @Path("/v1/internal/cob") @RequiredArgsConstructor diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java index 9d5d731d3..a92571978 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalLoanAccountLockApiResource.java @@ -35,13 +35,14 @@ import org.apache.fineract.cob.domain.LoanAccountLock; import org.apache.fineract.cob.domain.LoanAccountLockRepository; import org.apache.fineract.cob.domain.LockOwner; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.RequestBody; -@Profile("test") +@Profile(FineractProfiles.TEST) @Component @Path("/v1/internal/loans") @RequiredArgsConstructor diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStep.java b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStep.java new file mode 100644 index 000000000..fcce7307d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStep.java @@ -0,0 +1,69 @@ +/** + * 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.cob.loan; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountCustomSnapshotBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CheckDueInstallmentsBusinessStep implements LoanCOBBusinessStep { + + private final BusinessEventNotifierService businessEventNotifierService; + + @Override + public Loan execute(Loan loan) { + log.debug("start processing custom snapshot event trigger business step loan for loan with id [{}]", loan.getId()); + + if (loan.getRepaymentScheduleInstallments() != null && loan.getRepaymentScheduleInstallments().size() > 0) { + final LocalDate currentDate = DateUtils.getBusinessLocalDate(); + boolean shouldPostCustomSnapshotBusinessEvent = false; + for (int i = 0; i < loan.getRepaymentScheduleInstallments().size(); i++) { + if (loan.getRepaymentScheduleInstallments().get(i).getDueDate().equals(currentDate) + && loan.getRepaymentScheduleInstallments().get(i).isNotFullyPaidOff()) { + shouldPostCustomSnapshotBusinessEvent = true; + } + } + if (shouldPostCustomSnapshotBusinessEvent) { + businessEventNotifierService.notifyPostBusinessEvent(new LoanAccountCustomSnapshotBusinessEvent(loan)); + } + } + + log.debug("end processing custom snapshot event trigger business step for loan with id [{}]", loan.getId()); + return loan; + } + + @Override + public String getEnumStyledName() { + return "CHECK_DUE_INSTALLMENTS"; + } + + @Override + public String getHumanReadableName() { + return "Check Due Installments"; + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/instancemode/api/InstanceModeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/instancemode/api/InstanceModeApiResource.java index 215b75463..837638f63 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/instancemode/api/InstanceModeApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/instancemode/api/InstanceModeApiResource.java @@ -33,12 +33,13 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -@Profile("test") +@Profile(FineractProfiles.TEST) @Component @Path("/v1/instance-mode") @Tag(name = "Instance Mode", description = "Instance mode changing API") diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/s3/LocalstackS3ClientCustomizer.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/s3/LocalstackS3ClientCustomizer.java index 6b764aa6a..4e7d10853 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/s3/LocalstackS3ClientCustomizer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/s3/LocalstackS3ClientCustomizer.java @@ -20,6 +20,7 @@ package org.apache.fineract.infrastructure.s3; import java.net.URI; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; import org.apache.poi.util.StringUtil; import org.springframework.context.annotation.Profile; import org.springframework.core.env.Environment; @@ -28,7 +29,7 @@ import software.amazon.awssdk.services.s3.S3ClientBuilder; @Component @RequiredArgsConstructor -@Profile("test") +@Profile(FineractProfiles.TEST) public class LocalstackS3ClientCustomizer implements S3ClientCustomizer { private final Environment environment; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/InternalClientInformationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/InternalClientInformationApiResource.java index 6b1342b53..ae454ca08 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/InternalClientInformationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/InternalClientInformationApiResource.java @@ -37,6 +37,7 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; import org.apache.fineract.portfolio.client.domain.Client; @@ -45,7 +46,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -@Profile("test") +@Profile(FineractProfiles.TEST) @Component @Path("/v1/internal/client") @RequiredArgsConstructor diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/InternalLoanInformationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/InternalLoanInformationApiResource.java index 7969fd98f..a15d7f4a1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/InternalLoanInformationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/InternalLoanInformationApiResource.java @@ -38,6 +38,7 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.boot.FineractProfiles; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; import org.apache.fineract.portfolio.loanaccount.domain.Loan; @@ -50,7 +51,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -@Profile("test") +@Profile(FineractProfiles.TEST) @Component @Path("/v1/internal/loan") @RequiredArgsConstructor diff --git a/fineract-provider/src/test/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStepTest.java b/fineract-provider/src/test/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStepTest.java new file mode 100644 index 000000000..161123c8c --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/cob/loan/CheckDueInstallmentsBusinessStepTest.java @@ -0,0 +1,161 @@ +/** + * 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.cob.loan; + +import static org.mockito.Mockito.times; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.domain.ActionContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAccountCustomSnapshotBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class CheckDueInstallmentsBusinessStepTest { + + @Mock + private BusinessEventNotifierService businessEventNotifierService; + + @Captor + private ArgumentCaptor> businessEventArgumentCaptor; + + @InjectMocks + private CheckDueInstallmentsBusinessStep underTest; + + /** + * Setup context before each test. + */ + @BeforeEach + public void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.parse("2024-01-16"), + BusinessDateType.COB_DATE, LocalDate.parse("2024-01-15")))); + } + + @AfterEach + public void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + public void testNoRepaymentScheduleInLoan() { + // given + Loan loan = Mockito.mock(Loan.class); + + // when + underTest.execute(loan); + + // then + Mockito.verifyNoInteractions(businessEventNotifierService); + } + + @Test + public void testInstallmentDueDateIsNotMatchingWithCurrentBusinessDate() { + // given + Loan loan = Mockito.mock(Loan.class); + Mockito.when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(Mockito.mock(LoanRepaymentScheduleInstallment.class))); + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).getDueDate()).thenReturn(LocalDate.parse("2024-01-17")); + + // when + underTest.execute(loan); + + // then + Mockito.verifyNoInteractions(businessEventNotifierService); + } + + @Test + public void testSingleInstallmentDueDateIsMatchingWithCurrentBusinessDateAndNotFullyPayed() { + // given + Loan loan = Mockito.mock(Loan.class); + Mockito.when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(Mockito.mock(LoanRepaymentScheduleInstallment.class))); + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).getDueDate()).thenReturn(LocalDate.parse("2024-01-16")); + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).isNotFullyPaidOff()).thenReturn(true); + + // when + underTest.execute(loan); + + // then + Mockito.verify(businessEventNotifierService, times(1)).notifyPostBusinessEvent(businessEventArgumentCaptor.capture()); + BusinessEvent rawEvent = businessEventArgumentCaptor.getValue(); + Assertions.assertInstanceOf(LoanAccountCustomSnapshotBusinessEvent.class, rawEvent); + LoanAccountCustomSnapshotBusinessEvent event = (LoanAccountCustomSnapshotBusinessEvent) rawEvent; + Assertions.assertEquals(loan, event.get()); + } + + @Test + public void testSingleInstallmentDueDateIsMatchingWithCurrentBusinessDateAndFullyPayed() { + // given + Loan loan = Mockito.mock(Loan.class); + Mockito.when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(Mockito.mock(LoanRepaymentScheduleInstallment.class))); + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).getDueDate()).thenReturn(LocalDate.parse("2024-01-16")); + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).isNotFullyPaidOff()).thenReturn(false); + + // when + underTest.execute(loan); + + // then + Mockito.verifyNoInteractions(businessEventNotifierService); + } + + @Test + public void testMultipleInstallmentDueDateIsMatchingWithCurrentBusinessDateAndNotFullyPayedButSingleEventIsGenerated() { + // given + Loan loan = Mockito.mock(Loan.class); + Mockito.when(loan.getRepaymentScheduleInstallments()).thenReturn( + List.of(Mockito.mock(LoanRepaymentScheduleInstallment.class), Mockito.mock(LoanRepaymentScheduleInstallment.class))); + // first one is a down payment installment + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).getDueDate()).thenReturn(LocalDate.parse("2024-01-16")); + Mockito.when(loan.getRepaymentScheduleInstallments().get(0).isNotFullyPaidOff()).thenReturn(true); + Mockito.lenient().when(loan.getRepaymentScheduleInstallments().get(0).isDownPayment()).thenReturn(true); + // this one is a real installment + Mockito.when(loan.getRepaymentScheduleInstallments().get(1).getDueDate()).thenReturn(LocalDate.parse("2024-01-16")); + Mockito.when(loan.getRepaymentScheduleInstallments().get(1).isNotFullyPaidOff()).thenReturn(true); + + // when + underTest.execute(loan); + + // then + Mockito.verify(businessEventNotifierService, times(1)).notifyPostBusinessEvent(businessEventArgumentCaptor.capture()); + BusinessEvent rawEvent = businessEventArgumentCaptor.getValue(); + Assertions.assertInstanceOf(LoanAccountCustomSnapshotBusinessEvent.class, rawEvent); + LoanAccountCustomSnapshotBusinessEvent event = (LoanAccountCustomSnapshotBusinessEvent) rawEvent; + Assertions.assertEquals(loan, event.get()); + } + +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index ca46595cf..69b69d9db 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -98,7 +98,7 @@ public class ExternalEventConfigurationValidationServiceTest { "LoanChargeOffPostBusinessEvent", "LoanUndoChargeOffBusinessEvent", "LoanAccrualTransactionCreatedBusinessEvent", "LoanRescheduledDueAdjustScheduleBusinessEvent", "LoanOwnershipTransferBusinessEvent", "LoanAccountSnapshotBusinessEvent", "LoanTransactionDownPaymentPostBusinessEvent", "LoanTransactionDownPaymentPreBusinessEvent", - "LoanAccountDelinquencyPauseChangedBusinessEvent"); + "LoanAccountDelinquencyPauseChangedBusinessEvent", "LoanAccountCustomSnapshotBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); @@ -178,7 +178,7 @@ public class ExternalEventConfigurationValidationServiceTest { "LoanUndoChargeOffBusinessEvent", "LoanAccrualTransactionCreatedBusinessEvent", "LoanRescheduledDueAdjustScheduleBusinessEvent", "LoanOwnershipTransferBusinessEvent", "LoanAccountSnapshotBusinessEvent", "LoanTransactionDownPaymentPostBusinessEvent", "LoanTransactionDownPaymentPreBusinessEvent", - "LoanAccountDelinquencyPauseChangedBusinessEvent"); + "LoanAccountDelinquencyPauseChangedBusinessEvent", "LoanAccountCustomSnapshotBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index e04e414b0..dcc4c1d8a 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -74,6 +74,8 @@ import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.extension.ExtendWith; @@ -86,7 +88,7 @@ public abstract class BaseLoanIntegrationTest { protected static final String DATETIME_PATTERN = "dd MMMM yyyy"; - protected final ResponseSpecification responseSpec = createResponseSpecification(200); + protected final ResponseSpecification responseSpec = createResponseSpecification(Matchers.is(200)); protected final RequestSpecification requestSpec = createRequestSpecification(); protected final AccountHelper accountHelper = new AccountHelper(requestSpec, responseSpec); @@ -261,8 +263,8 @@ public abstract class BaseLoanIntegrationTest { return request; } - private static ResponseSpecification createResponseSpecification(int statusCode) { - return new ResponseSpecBuilder().expectStatusCode(statusCode).build(); + protected static ResponseSpecification createResponseSpecification(Matcher statusCodeMatcher) { + return new ResponseSpecBuilder().expectStatusCode(statusCodeMatcher).build(); } protected void verifyUndoLastDisbursalShallFail(Long loanId, String expectedError) { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java new file mode 100644 index 000000000..d2a29aa94 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/CustomSnapshotEventIntegrationTest.java @@ -0,0 +1,326 @@ +/** + * 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.integrationtests; + +import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; + +import com.google.gson.Gson; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.BusinessDateRequest; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.infrastructure.event.external.service.validation.ExternalEventDTO; +import org.apache.fineract.integrationtests.common.BusinessStepHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.ExternalEventConfigurationHelper; +import org.apache.fineract.integrationtests.common.SchedulerJobHelper; +import org.apache.fineract.integrationtests.common.externalevents.ExternalEventHelper; +import org.apache.fineract.integrationtests.common.externalevents.ExternalEventsExtension; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@Slf4j +@ExtendWith({ LoanTestLifecycleExtension.class, ExternalEventsExtension.class }) +public class CustomSnapshotEventIntegrationTest extends BaseLoanIntegrationTest { + + private SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); + + @Test + public void testSnapshotEventGenerationWhenLoanInstallmentIsNotPayed() { + runAt("31 January 2023", () -> { + // Enable Business Step + enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", + "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS"); + + enableLoanAccountCustomSnapshotBusinessEvent(); + + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // + installment(313.0, false, "02 March 2023"), // + installment(313.0, false, "01 April 2023"), // + installment(311.0, false, "01 May 2023") // + ); + + // delete all external events + deleteAllExternalEvents(); + + // run cob + updateBusinessDateAndExecuteCOBJob("01 February 2023"); + + // verify external events + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + Assertions.assertEquals(1, allExternalEvents.size()); + Assertions.assertEquals("LoanAccountCustomSnapshotBusinessEvent", allExternalEvents.get(0).getType()); + Assertions.assertEquals(loanId, allExternalEvents.get(0).getAggregateRootId()); + }); + } + + @Test + public void testNoSnapshotEventGenerationWhenLoanInstallmentIsPayed() { + runAt("31 January 2023", () -> { + // Enable Business Step + enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", + "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS"); + + enableLoanAccountCustomSnapshotBusinessEvent(); + + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // + installment(313.0, false, "02 March 2023"), // + installment(313.0, false, "01 April 2023"), // + installment(311.0, false, "01 May 2023") // + ); + + addRepaymentForLoan(loanId, 313.0, "31 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, true, "31 January 2023"), // + installment(313.0, false, "02 March 2023"), // + installment(313.0, false, "01 April 2023"), // + installment(311.0, false, "01 May 2023") // + ); + + // delete all external events + deleteAllExternalEvents(); + + // run cob + updateBusinessDateAndExecuteCOBJob("01 February 2023"); + + // verify external events + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + Assertions.assertEquals(0, allExternalEvents.size()); + }); + } + + @Test + public void testNoSnapshotEventGenerationWhenWhenCustomSnapshotEventCOBTaskIsNotActive() { + runAt("31 January 2023", () -> { + // Enable Business Step + enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", + "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER"); + + enableLoanAccountCustomSnapshotBusinessEvent(); + + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // + installment(313.0, false, "02 March 2023"), // + installment(313.0, false, "01 April 2023"), // + installment(311.0, false, "01 May 2023") // + ); + + // delete all external events + deleteAllExternalEvents(); + + // run cob + updateBusinessDateAndExecuteCOBJob("01 February 2023"); + + // verify external events + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + Assertions.assertEquals(0, allExternalEvents.size()); + }); + } + + @Test + public void testNoSnapshotEventGenerationWhenCOBDateIsNotMatchingWithInstallmentDueDate() { + runAt("30 January 2023", () -> { + // Enable Business Step + enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", + "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS"); + + enableLoanAccountCustomSnapshotBusinessEvent(); + + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // + installment(313.0, false, "02 March 2023"), // + installment(313.0, false, "01 April 2023"), // + installment(311.0, false, "01 May 2023") // + ); + + // delete all external events + deleteAllExternalEvents(); + + // run cob + updateBusinessDateAndExecuteCOBJob("31 January 2023"); + + // verify external events + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + Assertions.assertEquals(0, allExternalEvents.size()); + }); + } + + @Test + public void testNoSnapshotEventGenerationWhenCustomSnapshotEventIsDisabled() { + runAt("31 January 2023", () -> { + // Enable Business Step + enableCOBBusinessStep("APPLY_CHARGE_TO_OVERDUE_LOANS", "LOAN_DELINQUENCY_CLASSIFICATION", "CHECK_LOAN_REPAYMENT_DUE", + "CHECK_LOAN_REPAYMENT_OVERDUE", "UPDATE_LOAN_ARREARS_AGING", "ADD_PERIODIC_ACCRUAL_ENTRIES", + "EXTERNAL_ASSET_OWNER_TRANSFER", "CHECK_DUE_INSTALLMENTS"); + + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create Loan Product + PostLoanProductsRequest loanProductsRequest = create1InstallmentAmountInMultiplesOf4Period1MonthLongWithInterestAndAmortizationProduct( + InterestType.FLAT, AmortizationType.EQUAL_INSTALLMENTS); + loanProductsRequest.setEnableInstallmentLevelDelinquency(true); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductsRequest); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductResponse.getResourceId(), "01 January 2023", 1250.0, 4); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250), "01 January 2023"); + + // Verify Repayment Schedule and Due Dates + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, false, "31 January 2023"), // + installment(313.0, false, "02 March 2023"), // + installment(313.0, false, "01 April 2023"), // + installment(311.0, false, "01 May 2023") // + ); + + // delete all external events + deleteAllExternalEvents(); + + // run cob + updateBusinessDateAndExecuteCOBJob("01 February 2023"); + + // verify external events + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + Assertions.assertEquals(0, allExternalEvents.size()); + }); + } + + private void deleteAllExternalEvents() { + ExternalEventHelper.deleteAllExternalEvents(requestSpec, createResponseSpecification(Matchers.is(204))); + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + Assertions.assertEquals(0, allExternalEvents.size()); + } + + private void enableCOBBusinessStep(String... steps) { + new BusinessStepHelper().updateSteps("LOAN_CLOSE_OF_BUSINESS", steps); + + } + + public static String getExternalEventConfigurationsForUpdateJSON() { + Map> configurationsForUpdate = new HashMap<>(); + Map configurations = new HashMap<>(); + configurations.put("CentersCreateBusinessEvent", true); + configurations.put("ClientActivateBusinessEvent", true); + configurationsForUpdate.put("externalEventConfigurations", configurations); + return new Gson().toJson(configurationsForUpdate); + } + + private void enableLoanAccountCustomSnapshotBusinessEvent() { + final Map updatedConfigurations = ExternalEventConfigurationHelper.updateExternalEventConfigurations(requestSpec, + responseSpec, "{\"externalEventConfigurations\":{\"LoanAccountCustomSnapshotBusinessEvent\":true}}\n"); + Assertions.assertEquals(updatedConfigurations.size(), 1); + Assertions.assertTrue(updatedConfigurations.containsKey("LoanAccountCustomSnapshotBusinessEvent")); + Assertions.assertTrue(updatedConfigurations.get("LoanAccountCustomSnapshotBusinessEvent")); + } + + private void updateBusinessDateAndExecuteCOBJob(String date) { + businessDateHelper.updateBusinessDate( + new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en")); + schedulerJobHelper.executeAndAwaitJob("Loan COB"); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java index 29bdab718..42c5e1dd3 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java @@ -495,6 +495,11 @@ public class ExternalEventConfigurationHelper { loanAccountDelinquencyPauseChangedBusinessEvent.put("enabled", false); defaults.add(loanAccountDelinquencyPauseChangedBusinessEvent); + Map loanAccountCustomSnapshotBusinessEvent = new HashMap<>(); + loanAccountCustomSnapshotBusinessEvent.put("type", "LoanAccountCustomSnapshotBusinessEvent"); + loanAccountCustomSnapshotBusinessEvent.put("enabled", false); + defaults.add(loanAccountCustomSnapshotBusinessEvent); + return defaults; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/Utils.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/Utils.java index 5fc131b0e..261f7b976 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/Utils.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/Utils.java @@ -283,6 +283,9 @@ public final class Utils { final String deleteURL, final String jsonAttributeToGetBack) { final String json = given().spec(requestSpec).expect().spec(responseSpec).log().ifError().when().delete(deleteURL).andReturn() .asString(); + if (jsonAttributeToGetBack == null) { + return (T) json; + } return (T) JsonPath.from(json).get(jsonAttributeToGetBack); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventHelper.java new file mode 100644 index 000000000..793e14444 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventHelper.java @@ -0,0 +1,92 @@ +/** + * 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.integrationtests.common.externalevents; + +import com.google.common.reflect.TypeToken; +import com.google.gson.Gson; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.List; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.util.JSON; +import org.apache.fineract.infrastructure.event.external.service.validation.ExternalEventDTO; +import org.apache.fineract.integrationtests.common.Utils; + +@Slf4j +public final class ExternalEventHelper { + + private static final Gson GSON = new JSON().getGson(); + + private ExternalEventHelper() {} + + @Builder + public static class Filter { + + private final String idempotencyKey; + private final String type; + private final String category; + private final Long aggregateRootId; + + public String toQueryParams() { + StringBuilder stringBuilder = new StringBuilder(); + if (idempotencyKey != null) { + stringBuilder.append("idempotencyKey=").append(idempotencyKey).append("&"); + } + + if (type != null) { + stringBuilder.append("type=").append(type).append("&"); + } + + if (category != null) { + stringBuilder.append("category=").append(category).append("&"); + } + + if (aggregateRootId != null) { + stringBuilder.append("aggregateRootId=").append(aggregateRootId).append("&"); + } + + return stringBuilder.toString(); + + } + } + + public static List getAllExternalEvents(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec) { + final String url = "/fineract-provider/api/v1/internal/externalevents?" + Utils.TENANT_IDENTIFIER; + log.info("---------------------------------GETTING ALL EXTERNAL EVENTS---------------------------------------------"); + String response = Utils.performServerGet(requestSpec, responseSpec, url); + return GSON.fromJson(response, new TypeToken>() {}.getType()); + } + + public static List getAllExternalEvents(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec, Filter filter) { + final String url = "/fineract-provider/api/v1/internal/externalevents?" + filter.toQueryParams() + Utils.TENANT_IDENTIFIER; + log.info("---------------------------------GETTING ALL EXTERNAL EVENTS---------------------------------------------"); + String response = Utils.performServerGet(requestSpec, responseSpec, url); + return GSON.fromJson(response, new TypeToken>() {}.getType()); + } + + public static void deleteAllExternalEvents(final RequestSpecification requestSpec, final ResponseSpecification responseSpec) { + final String url = "/fineract-provider/api/v1/internal/externalevents?" + Utils.TENANT_IDENTIFIER; + log.info("-----------------------------DELETE ALL EXTERNAL EVENTS PARTITIONS----------------------------------------"); + Utils.performServerDelete(requestSpec, responseSpec, url, null); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventsExtension.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventsExtension.java new file mode 100644 index 000000000..80befb045 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/externalevents/ExternalEventsExtension.java @@ -0,0 +1,88 @@ +/** + * 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.integrationtests.common.externalevents; + +import static org.apache.fineract.integrationtests.common.Utils.initializeRESTAssured; + +import com.google.common.collect.MapDifference; +import com.google.common.collect.Maps; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.ArrayList; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.integrationtests.common.ExternalEventConfigurationHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +@Slf4j +public class ExternalEventsExtension implements AfterEachCallback, BeforeEachCallback { + + private Map original; + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + + public ExternalEventsExtension() { + initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.requestSpec.header("Fineract-Platform-TenantId", "default"); + } + + @Override + public void afterEach(ExtensionContext context) { + ArrayList> allExternalEventConfigurations = ExternalEventConfigurationHelper + .getAllExternalEventConfigurations(requestSpec, responseSpec); + Map collected = allExternalEventConfigurations.stream() + .map(map -> Map.entry((String) map.get("type"), (Boolean) map.get("enabled"))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Map> diff = Maps.difference(original, collected).entriesDiffering(); + diff.keySet().forEach(key -> { + MapDifference.ValueDifference valueDifference = diff.get(key); + log.debug("External event {} changed from {} to {}. Restoring to its original state.", key, valueDifference.leftValue(), + valueDifference.rightValue()); + restore(key, valueDifference.leftValue()); + }); + } + + @Override + public void beforeEach(ExtensionContext context) { + ArrayList> allExternalEventConfigurations = ExternalEventConfigurationHelper + .getAllExternalEventConfigurations(requestSpec, responseSpec); + original = allExternalEventConfigurations.stream().map(map -> Map.entry((String) map.get("type"), (Boolean) map.get("enabled"))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private void restore(String key, Boolean value) { + final Map updatedConfigurations = ExternalEventConfigurationHelper.updateExternalEventConfigurations(requestSpec, + responseSpec, "{\"externalEventConfigurations\":{\"" + key + "\":" + value + "}}\n"); + Assertions.assertEquals(updatedConfigurations.size(), 1); + Assertions.assertTrue(updatedConfigurations.containsKey(key)); + Assertions.assertEquals(value, updatedConfigurations.get(key)); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/CobHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/CobHelper.java index 6b4188a36..9e7bb38d6 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/CobHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/CobHelper.java @@ -23,16 +23,17 @@ import io.restassured.specification.ResponseSpecification; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.integrationtests.client.IntegrationTest; import org.apache.fineract.integrationtests.common.Utils; @Slf4j -public class CobHelper extends IntegrationTest { +public final class CobHelper { + + private CobHelper() {} public static List> getCobPartitions(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, int partitionSize, final String jsonReturn) { - final String GET_LOAN_URL = "/fineract-provider/api/v1/internal/cob/partitions/" + partitionSize + "?" + Utils.TENANT_IDENTIFIER; + final String url = "/fineract-provider/api/v1/internal/cob/partitions/" + partitionSize + "?" + Utils.TENANT_IDENTIFIER; log.info("---------------------------------GET COB PARTITIONS---------------------------------------------"); - return Utils.performServerGet(requestSpec, responseSpec, GET_LOAN_URL, jsonReturn); + return Utils.performServerGet(requestSpec, responseSpec, url, jsonReturn); } }