diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java index 7a5d647c87d..beb28d0f9ef 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java @@ -449,6 +449,7 @@ public static class UserNotificationSystemProperties { public static class FineractLoanProperties { private FineractTransactionProcessorProperties transactionProcessor; + private String statusChangeHistoryStatuses; } @Getter diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanStatusChangeHistory.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanStatusChangeHistory.java new file mode 100644 index 00000000000..371a0915e36 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanStatusChangeHistory.java @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +@Entity +@Table(name = "m_loan_status_change_history") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoanStatusChangeHistory extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne + @JoinColumn(name = "loan_id", nullable = false) + private Loan loan; + + @Enumerated(EnumType.STRING) + @Column(name = "status_code", nullable = false) + @Setter + private LoanStatus status; + + @Column(name = "status_change_business_date", nullable = false) + private LocalDate businessDate; + +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanStatusChangeHistoryRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanStatusChangeHistoryRepository.java new file mode 100644 index 00000000000..45560d20223 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanStatusChangeHistoryRepository.java @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface LoanStatusChangeHistoryRepository + extends JpaRepository, JpaSpecificationExecutor {} 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 e165bedd218..c6624ece238 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 @@ -44,4 +44,5 @@ + diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1021_add_loan_status_change_history.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1021_add_loan_status_change_history.xml new file mode 100644 index 00000000000..413bcf35305 --- /dev/null +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1021_add_loan_status_change_history.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangeHistoryListener.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangeHistoryListener.java new file mode 100644 index 00000000000..1048d620a5d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangeHistoryListener.java @@ -0,0 +1,94 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service; + +import com.google.common.base.Splitter; +import jakarta.annotation.PostConstruct; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.event.business.BusinessEventListener; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistory; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistoryRepository; +import org.springframework.stereotype.Service; + +@Slf4j +@RequiredArgsConstructor +@Service +public class LoanStatusChangeHistoryListener { + + private final Set loanStatuses = new HashSet<>(); + + private final BusinessEventNotifierService businessEventNotifierService; + private final LoanStatusChangeHistoryRepository loanStatusChangeHistoryRepository; + private final FineractProperties fineractProperties; + + @PostConstruct + public void addListeners() { + loanStatuses.addAll(getLoanStatuses(fineractProperties.getLoan().getStatusChangeHistoryStatuses())); + if (loanStatuses.size() > 0) { + businessEventNotifierService.addPostBusinessEventListener(LoanStatusChangedBusinessEvent.class, + new LoanStatusChangedListener()); + } + } + + Set getLoanStatuses(String str) { + Set result = new HashSet<>(); + if ("NONE".equals(StringUtils.trim(str))) { + return result; + } else if ("ALL".equals(StringUtils.trim(str))) { + return Arrays.stream(LoanStatus.values()).collect(Collectors.toSet()); + } else { + List split = Splitter.on(",").trimResults().omitEmptyStrings().splitToList(str); + for (int i = 0; i < split.size(); i++) { + try { + result.add(Enum.valueOf(LoanStatus.class, split.get(i))); + } catch (IllegalArgumentException iae) { + throw new RuntimeException("Invalid loan status: " + split.get(i), iae); + } + } + } + return result; + } + + protected final class LoanStatusChangedListener implements BusinessEventListener { + + @Override + public void onBusinessEvent(LoanStatusChangedBusinessEvent event) { + final Loan loan = event.get(); + log.debug("Loan Status change for loan {} with status {}", loan.getId(), loan.getStatus()); + if (loanStatuses.contains(loan.getStatus())) { + LoanStatusChangeHistory loanStatusChangeHistory = new LoanStatusChangeHistory(loan, loan.getStatus(), + DateUtils.getBusinessLocalDate()); + loanStatusChangeHistoryRepository.saveAndFlush(loanStatusChangeHistory); + } + } + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 77624ba83ca..a49c6403cc8 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -142,6 +142,10 @@ fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance fineract.loan.transactionprocessor.advanced-payment-strategy.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_ADVANCED_PAYMENT_STRATEGY_ENABLED:true} fineract.loan.transactionprocessor.error-not-found-fail=${FINERACT_LOAN_TRANSACTIONPROCESSOR_ERROR_NOT_FOUND_FAIL:true} +# Comma separated list of loan statuses which will be recorded on change. There are two extra values: "NONE" and "ALL". +# "NONE" disables the feature and no entries will be created, "ALL" enables the feature for all loan statuses. +fineract.loan.status-change-history-statuses=${FINERACT_LOAN_STATUS_CHANGE_HISTORY_STATUSES:NONE} + fineract.content.regex-whitelist-enabled=${FINERACT_CONTENT_REGEX_WHITELIST_ENABLED:true} fineract.content.regex-whitelist=${FINERACT_CONTENT_REGEX_WHITELIST:.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png} fineract.content.mime-whitelist-enabled=${FINERACT_CONTENT_MIME_WHITELIST_ENABLED:true} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangeHistoryListenerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangeHistoryListenerTest.java new file mode 100644 index 00000000000..43c6e575af7 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangeHistoryListenerTest.java @@ -0,0 +1,210 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +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.BusinessEventListener; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistory; +import org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistoryRepository; +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 LoanStatusChangeHistoryListenerTest { + + @Mock + private BusinessEventNotifierService businessEventNotifierService; + @Mock + private LoanStatusChangeHistoryRepository loanStatusChangeHistoryRepository; + @Mock + private FineractProperties fineractProperties; + + @Captor + private ArgumentCaptor> classArgumentCaptor; + + @Captor + private ArgumentCaptor> listenerCaptor; + + @Captor + private ArgumentCaptor loanStatusChangeHistoryArgumentCaptor; + + @InjectMocks + private LoanStatusChangeHistoryListener underTest; + + private final LocalDate actualDate = LocalDate.now(ZoneId.systemDefault()); + + @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, actualDate))); + } + + @Test + public void testGetLoanStatusesParseOK() { + Assertions.assertEquals(Set.of(), underTest.getLoanStatuses("NONE")); + Assertions.assertEquals(Arrays.stream(LoanStatus.values()).collect(Collectors.toSet()), underTest.getLoanStatuses("ALL")); + Assertions.assertEquals(Set.of(LoanStatus.OVERPAID, LoanStatus.REJECTED, LoanStatus.CLOSED_OBLIGATIONS_MET), + underTest.getLoanStatuses("OVERPAID,REJECTED,CLOSED_OBLIGATIONS_MET")); + Assertions.assertEquals(Set.of(LoanStatus.OVERPAID, LoanStatus.REJECTED, LoanStatus.CLOSED_OBLIGATIONS_MET), + underTest.getLoanStatuses(" OVERPAID, REJECTED ,CLOSED_OBLIGATIONS_MET ")); + } + + @Test + public void testGetLoanStatusesParseNOK() { + RuntimeException runtimeException = Assertions.assertThrows(RuntimeException.class, () -> underTest.getLoanStatuses("MISSING")); + Assertions.assertEquals("Invalid loan status: MISSING", runtimeException.getMessage()); + + runtimeException = Assertions.assertThrows(RuntimeException.class, () -> underTest.getLoanStatuses("OVERPAID,MISSING")); + Assertions.assertEquals("Invalid loan status: MISSING", runtimeException.getMessage()); + + runtimeException = Assertions.assertThrows(RuntimeException.class, () -> underTest.getLoanStatuses("ACTIVE,ALL")); + Assertions.assertEquals("Invalid loan status: ALL", runtimeException.getMessage()); + + runtimeException = Assertions.assertThrows(RuntimeException.class, () -> underTest.getLoanStatuses("APPROVED,NONE")); + Assertions.assertEquals("Invalid loan status: NONE", runtimeException.getMessage()); + } + + @Test + public void testEventListenerShouldNotBeRegisteredWhenNONE() { + // given + FineractProperties.FineractLoanProperties loanProperties = Mockito.mock(FineractProperties.FineractLoanProperties.class); + Mockito.when(fineractProperties.getLoan()).thenReturn(loanProperties); + Mockito.when(loanProperties.getStatusChangeHistoryStatuses()).thenReturn("NONE"); + + // when + underTest.addListeners(); + + // then + Mockito.verifyNoInteractions(businessEventNotifierService); + } + + @Test + public void testEventListenerShouldBeRegisteredWhenAll() { + // given + FineractProperties.FineractLoanProperties loanProperties = Mockito.mock(FineractProperties.FineractLoanProperties.class); + Mockito.when(fineractProperties.getLoan()).thenReturn(loanProperties); + Mockito.when(loanProperties.getStatusChangeHistoryStatuses()).thenReturn("ALL"); + + // when + underTest.addListeners(); + + // then + Mockito.verify(businessEventNotifierService, Mockito.times(1)).addPostBusinessEventListener(classArgumentCaptor.capture(), + listenerCaptor.capture()); + Mockito.verifyNoMoreInteractions(businessEventNotifierService); + Assertions.assertNotNull(listenerCaptor.getValue()); + } + + @Test + public void testEventListenerShouldBeRegisteredWhenValidStatusesAreProvided() { + // given + FineractProperties.FineractLoanProperties loanProperties = Mockito.mock(FineractProperties.FineractLoanProperties.class); + Mockito.when(fineractProperties.getLoan()).thenReturn(loanProperties); + Mockito.when(loanProperties.getStatusChangeHistoryStatuses()).thenReturn("ACTIVE, REJECTED"); + + // when + underTest.addListeners(); + + // then + Mockito.verify(businessEventNotifierService, Mockito.times(1)).addPostBusinessEventListener(classArgumentCaptor.capture(), + listenerCaptor.capture()); + Mockito.verifyNoMoreInteractions(businessEventNotifierService); + Assertions.assertNotNull(listenerCaptor.getValue()); + } + + @Test + public void testHistoryIsSavedWhenLoansStateIsConfigured() { + // given + FineractProperties.FineractLoanProperties loanProperties = Mockito.mock(FineractProperties.FineractLoanProperties.class); + Mockito.when(fineractProperties.getLoan()).thenReturn(loanProperties); + Mockito.when(loanProperties.getStatusChangeHistoryStatuses()).thenReturn("ACTIVE, REJECTED"); + underTest.addListeners(); + Mockito.verify(businessEventNotifierService, Mockito.times(1)).addPostBusinessEventListener(classArgumentCaptor.capture(), + listenerCaptor.capture()); + Mockito.verifyNoMoreInteractions(businessEventNotifierService); + BusinessEventListener listener = listenerCaptor.getValue(); + + LoanStatusChangedBusinessEvent mockEvent = Mockito.mock(LoanStatusChangedBusinessEvent.class); + Loan loan = Mockito.mock(Loan.class); + Mockito.when(loan.getId()).thenReturn(123L); + Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE); + Mockito.when(mockEvent.get()).thenReturn(loan); + + // when + listener.onBusinessEvent(mockEvent); + + // then + Mockito.verify(loanStatusChangeHistoryRepository, Mockito.times(1)).saveAndFlush(loanStatusChangeHistoryArgumentCaptor.capture()); + Mockito.verifyNoMoreInteractions(loanStatusChangeHistoryRepository); + LoanStatusChangeHistory loanStatusChangeHistory = loanStatusChangeHistoryArgumentCaptor.getValue(); + Assertions.assertEquals(loan, loanStatusChangeHistory.getLoan()); + Assertions.assertEquals(LoanStatus.ACTIVE, loanStatusChangeHistory.getStatus()); + Assertions.assertEquals(actualDate, loanStatusChangeHistory.getBusinessDate()); + } + + @Test + public void testHistoryIsNotSavedWhenLoansStateIsNotConfigured() { + // given + FineractProperties.FineractLoanProperties loanProperties = Mockito.mock(FineractProperties.FineractLoanProperties.class); + Mockito.when(fineractProperties.getLoan()).thenReturn(loanProperties); + Mockito.when(loanProperties.getStatusChangeHistoryStatuses()).thenReturn("ACTIVE, REJECTED"); + underTest.addListeners(); + Mockito.verify(businessEventNotifierService, Mockito.times(1)).addPostBusinessEventListener(classArgumentCaptor.capture(), + listenerCaptor.capture()); + Mockito.verifyNoMoreInteractions(businessEventNotifierService); + BusinessEventListener listener = listenerCaptor.getValue(); + + LoanStatusChangedBusinessEvent mockEvent = Mockito.mock(LoanStatusChangedBusinessEvent.class); + Loan loan = Mockito.mock(Loan.class); + Mockito.when(loan.getId()).thenReturn(123L); + Mockito.when(loan.getStatus()).thenReturn(LoanStatus.OVERPAID); + Mockito.when(mockEvent.get()).thenReturn(loan); + + // when + listener.onBusinessEvent(mockEvent); + + // then + Mockito.verifyNoInteractions(loanStatusChangeHistoryRepository); + } + +} diff --git a/fineract-provider/src/test/resources/application-test.properties b/fineract-provider/src/test/resources/application-test.properties index 36c7231ae1c..9f9eb43ce99 100644 --- a/fineract-provider/src/test/resources/application-test.properties +++ b/fineract-provider/src/test/resources/application-test.properties @@ -73,6 +73,7 @@ fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=true fineract.loan.transactionprocessor.advanced-payment-strategy.enabled=true fineract.loan.transactionprocessor.error-not-found-fail=true +fineract.loan.status-change-history-statuses=NONE fineract.content.regex-whitelist-enabled=true fineract.content.regex-whitelist=.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png