From 7be193dd0b95f7f5b51a23908e08e843f22881a3 Mon Sep 17 00:00:00 2001 From: taskain7 Date: Mon, 17 Oct 2022 12:12:48 +0200 Subject: [PATCH] [FINERACT-1678] Reject write API calls for hard-locked loan accounts --- .../cob/service/LoanAccountLockService.java | 2 + .../service/LoanAccountLockServiceImpl.java | 12 ++ .../core/config/SecurityConfig.java | 4 + .../core/data/ApiGlobalErrorResponse.java | 10 ++ .../jobs/filter/LoanCOBApiFilter.java | 133 +++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 29 ++-- .../jobs/filter/LoanCOBApiFilterTest.java | 135 ++++++++++++++++++ 7 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilter.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilterTest.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java b/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java index e1daca429..7f584d6ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockService.java @@ -24,4 +24,6 @@ import org.apache.fineract.cob.domain.LoanAccountLock; public interface LoanAccountLockService { List getLockedLoanAccountByPage(int page, int limit); + + boolean isLoanHardLocked(Long loanId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockServiceImpl.java index 63a2cd606..4eeb31570 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/service/LoanAccountLockServiceImpl.java @@ -19,9 +19,12 @@ package org.apache.fineract.cob.service; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.cob.domain.LoanAccountLock; import org.apache.fineract.cob.domain.LoanAccountLockRepository; +import org.apache.fineract.cob.domain.LockOwner; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -39,4 +42,13 @@ public class LoanAccountLockServiceImpl implements LoanAccountLockService { Page loanAccountLocks = loanAccountLockRepository.findAll(loanAccountLockPage); return loanAccountLocks.getContent(); } + + @Override + public boolean isLoanHardLocked(Long loanId) { + Optional loanAccountLockOptional = loanAccountLockRepository.findById(loanId); + return loanAccountLockOptional // + .filter(loanAccountLock -> LockOwner.LOAN_COB_CHUNK_PROCESSING.equals(loanAccountLock.getLockOwner()) // + || LockOwner.LOAN_INLINE_COB_PROCESSING.equals(loanAccountLock.getLockOwner())) // + .filter(loanAccountLock -> StringUtils.isBlank(loanAccountLock.getError())).isPresent(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index eb3005199..3901e092f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -20,6 +20,7 @@ package org.apache.fineract.infrastructure.core.config; import org.apache.fineract.infrastructure.instancemode.filter.FineractInstanceModeApiFilter; +import org.apache.fineract.infrastructure.jobs.filter.LoanCOBApiFilter; import org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter; import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter; import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService; @@ -56,6 +57,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private FineractInstanceModeApiFilter fineractInstanceModeApiFilter; + @Autowired + private LoanCOBApiFilter loanCOBApiFilter; @Autowired private FineractProperties fineractProperties; @@ -86,6 +89,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { .and() // .addFilterAfter(fineractInstanceModeApiFilter, SecurityContextPersistenceFilter.class) // .addFilterAfter(tenantAwareBasicAuthenticationFilter(), FineractInstanceModeApiFilter.class) // + .addFilterAfter(loanCOBApiFilter, TenantAwareBasicAuthenticationFilter.class) // .addFilterAfter(twoFactorAuthenticationFilter, BasicAuthenticationFilter.class); // if (serverProperties.getSsl().isEnabled()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/data/ApiGlobalErrorResponse.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/data/ApiGlobalErrorResponse.java index 753fe555b..8bf5e4664 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/data/ApiGlobalErrorResponse.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/data/ApiGlobalErrorResponse.java @@ -89,6 +89,16 @@ public class ApiGlobalErrorResponse { return globalErrorResponse; } + public static ApiGlobalErrorResponse loanIsLocked(final Long loanId) { + final ApiGlobalErrorResponse globalErrorResponse = new ApiGlobalErrorResponse(); + globalErrorResponse.setHttpStatusCode(Status.CONFLICT.toString()); + globalErrorResponse.setDeveloperMessage("Loan is locked by the COB job. Loan ID: " + loanId); + globalErrorResponse.setUserMessageGlobalisationCode("error.msg.loan.locked"); + globalErrorResponse.setDefaultUserMessage("Loan is locked by the COB job. Loan ID: \" + loanId"); + + return globalErrorResponse; + } + public static ApiGlobalErrorResponse unAuthorized(final String defaultUserMessage) { final ApiGlobalErrorResponse globalErrorResponse = new ApiGlobalErrorResponse(); globalErrorResponse.setHttpStatusCode("403"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilter.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilter.java new file mode 100644 index 000000000..099161fd0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilter.java @@ -0,0 +1,133 @@ +/** + * 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.jobs.filter; + +import com.google.common.base.Splitter; +import com.sun.research.ws.wadl.HTTPMethods; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.cob.service.LoanAccountLockService; +import org.apache.fineract.infrastructure.core.data.ApiGlobalErrorResponse; +import org.apache.fineract.portfolio.loanaccount.domain.GLIMAccountInfoRepository; +import org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonitoringAccount; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class LoanCOBApiFilter extends OncePerRequestFilter { + + private final GLIMAccountInfoRepository glimAccountInfoRepository; + private final LoanAccountLockService loanAccountLockService; + + private static final List HTTP_METHODS = List.of(HTTPMethods.POST, HTTPMethods.PUT, HTTPMethods.DELETE); + private static final Function URL_FUNCTION = s -> s.matches("/loans/\\d+.*") || s.matches("/loans/glimAccount/\\d+.*"); + private static final Integer LOAN_ID_INDEX_IN_URL = 2; + private static final Integer GLIM_ID_INDEX_IN_URL = 3; + private static final Integer GLIM_STRING_INDEX_IN_URL = 2; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!isOnApiList(request)) { + proceed(filterChain, request, response); + } else { + Iterable split = Splitter.on('/').split(request.getPathInfo()); + Supplier> streamSupplier = () -> StreamSupport.stream(split.spliterator(), false); + boolean isGlim = isGlim(streamSupplier); + Long loanId = getLoanId(isGlim, streamSupplier); + if (isLoanLocked(loanId, isGlim)) { + reject(loanId, response); + } else { + proceed(filterChain, request, response); + } + } + } + + private boolean isLoanLocked(Long loanId, boolean isGlim) { + if (!isGlim) { + return loanAccountLockService.isLoanHardLocked(loanId); + } else { + GroupLoanIndividualMonitoringAccount glimAccount = glimAccountInfoRepository.findOneByIsAcceptingChildAndApplicationId(true, + BigDecimal.valueOf(loanId)); + if (glimAccount != null) { + Set loans = glimAccount.getChildLoan(); + List loanIds = loans.stream().map(Loan::getId).toList(); + return loanIds.stream().anyMatch(loanAccountLockService::isLoanHardLocked); + } else { + return false; + } + } + } + + private void proceed(FilterChain filterChain, HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + filterChain.doFilter(request, response); + } + + private void reject(Long loanId, HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.SC_CONFLICT); + ApiGlobalErrorResponse errorResponse = ApiGlobalErrorResponse.loanIsLocked(loanId); + response.getWriter().write(errorResponse.toJson()); + } + + private Long getLoanId(boolean isGlim, Supplier> streamSupplier) { + if (!isGlim) { + if (streamSupplier.get().count() >= LOAN_ID_INDEX_IN_URL + 1) { + return Long.valueOf(streamSupplier.get().skip(LOAN_ID_INDEX_IN_URL).findFirst().get()); + } else { + return null; + } + } else { + if (streamSupplier.get().count() >= GLIM_ID_INDEX_IN_URL + 1) { + return Long.valueOf(streamSupplier.get().skip(GLIM_ID_INDEX_IN_URL).findFirst().get()); + } else { + return null; + } + } + } + + private boolean isOnApiList(HttpServletRequest request) { + if (StringUtils.isBlank(request.getPathInfo())) { + return false; + } + return HTTP_METHODS.contains(HTTPMethods.fromValue(request.getMethod())) && URL_FUNCTION.apply(request.getPathInfo()); + } + + private boolean isGlim(Supplier> streamSupplier) { + if (streamSupplier.get().count() >= GLIM_STRING_INDEX_IN_URL + 1) { + return "glimAccount".equals(streamSupplier.get().skip(GLIM_STRING_INDEX_IN_URL).findFirst().get()); + } + return false; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 349546f1c..1db95735b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -39,6 +39,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.cob.domain.LoanAccountLockRepository; +import org.apache.fineract.cob.exceptions.LoanAccountLockCannotBeOverruledException; +import org.apache.fineract.cob.service.LoanAccountLockService; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; @@ -278,6 +281,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf private final PostDatedChecksRepository postDatedChecksRepository; private final LoanDisbursementDetailsRepository loanDisbursementDetailsRepository; private final LoanRepaymentScheduleInstallmentRepository loanRepaymentScheduleInstallmentRepository; + private final LoanAccountLockRepository loanAccountLockRepository; + private final LoanAccountLockService loanAccountLockService; private static boolean isPartOfThisInstallment(LoanCharge loanCharge, LoanRepaymentScheduleInstallment e) { return e.getFromDate().isBefore(loanCharge.getDueDate()) && !loanCharge.getDueDate().isAfter(e.getDueDate()); @@ -2551,20 +2556,28 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf final Staff fromLoanOfficer = this.loanAssembler.findLoanOfficerByIdIfProvided(fromLoanOfficerId); final Staff toLoanOfficer = this.loanAssembler.findLoanOfficerByIdIfProvided(toLoanOfficerId); + List lockedLoanIds = new ArrayList<>(); for (final String loanIdString : loanIds) { final Long loanId = Long.valueOf(loanIdString); final Loan loan = this.loanAssembler.assembleFrom(loanId); - businessEventNotifierService.notifyPreBusinessEvent(new LoanReassignOfficerBusinessEvent(loan)); - checkClientOrGroupActive(loan); + if (loanAccountLockService.isLoanHardLocked(loanId)) { + lockedLoanIds.add(loanId); + } else { + businessEventNotifierService.notifyPreBusinessEvent(new LoanReassignOfficerBusinessEvent(loan)); + checkClientOrGroupActive(loan); - if (!loan.hasLoanOfficer(fromLoanOfficer)) { - throw new LoanOfficerAssignmentException(loanId, fromLoanOfficerId); + if (!loan.hasLoanOfficer(fromLoanOfficer)) { + throw new LoanOfficerAssignmentException(loanId, fromLoanOfficerId); + } + + loan.reassignLoanOfficer(toLoanOfficer, dateOfLoanOfficerAssignment); + saveLoanWithDataIntegrityViolationChecks(loan); + businessEventNotifierService.notifyPostBusinessEvent(new LoanReassignOfficerBusinessEvent(loan)); } - - loan.reassignLoanOfficer(toLoanOfficer, dateOfLoanOfficerAssignment); - saveLoanWithDataIntegrityViolationChecks(loan); - businessEventNotifierService.notifyPostBusinessEvent(new LoanReassignOfficerBusinessEvent(loan)); + } + if (!lockedLoanIds.isEmpty()) { + throw new LoanAccountLockCannotBeOverruledException("There are hard-lcoked loan accounts: " + lockedLoanIds); } this.loanRepositoryWrapper.flush(); diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilterTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilterTest.java new file mode 100644 index 000000000..de7bf1ce1 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/jobs/filter/LoanCOBApiFilterTest.java @@ -0,0 +1,135 @@ +/** + * 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.jobs.filter; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.sun.research.ws.wadl.HTTPMethods; +import java.io.IOException; +import java.io.PrintWriter; +import java.math.BigDecimal; +import java.util.Collections; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import org.apache.fineract.cob.service.LoanAccountLockService; +import org.apache.fineract.portfolio.loanaccount.domain.GLIMAccountInfoRepository; +import org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonitoringAccount; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class LoanCOBApiFilterTest { + + @InjectMocks + private LoanCOBApiFilter testObj; + @Mock + private LoanAccountLockService loanAccountLockService; + @Mock + private GLIMAccountInfoRepository glimAccountInfoRepository; + + @Test + void shouldProceedWhenUrlDoesNotMatch() throws ServletException, IOException { + MockHttpServletRequest request = mock(MockHttpServletRequest.class); + given(request.getPathInfo()).willReturn("/jobs/2/inline"); + given(request.getMethod()).willReturn(HTTPMethods.POST.value()); + MockHttpServletResponse response = mock(MockHttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + testObj.doFilterInternal(request, response, filterChain); + verify(filterChain, times(1)).doFilter(request, response); + } + + @Test + void shouldProceedWhenLoanIsNotLocked() throws ServletException, IOException { + MockHttpServletRequest request = mock(MockHttpServletRequest.class); + MockHttpServletResponse response = mock(MockHttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + given(request.getPathInfo()).willReturn("/loans/2/charges"); + given(request.getMethod()).willReturn(HTTPMethods.POST.value()); + given(loanAccountLockService.isLoanHardLocked(2L)).willReturn(false); + + testObj.doFilterInternal(request, response, filterChain); + verify(filterChain, times(1)).doFilter(request, response); + } + + @Test + void shouldProceedWhenLoanIsSoftLocked() throws ServletException, IOException { + MockHttpServletRequest request = mock(MockHttpServletRequest.class); + MockHttpServletResponse response = mock(MockHttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + given(request.getPathInfo()).willReturn("/loans/2/charges"); + given(request.getMethod()).willReturn(HTTPMethods.POST.value()); + given(loanAccountLockService.isLoanHardLocked(2L)).willReturn(false); + + testObj.doFilterInternal(request, response, filterChain); + verify(filterChain, times(1)).doFilter(request, response); + } + + @Test + void shouldRejectWhenLoanIsHardLocked() throws ServletException, IOException { + MockHttpServletRequest request = mock(MockHttpServletRequest.class); + MockHttpServletResponse response = mock(MockHttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + PrintWriter writer = mock(PrintWriter.class); + + given(request.getPathInfo()).willReturn("/loans/2/charges"); + given(request.getMethod()).willReturn(HTTPMethods.POST.value()); + given(loanAccountLockService.isLoanHardLocked(2L)).willReturn(true); + given(response.getWriter()).willReturn(writer); + + testObj.doFilterInternal(request, response, filterChain); + verify(response, times(1)).setStatus(HttpStatus.SC_CONFLICT); + } + + @Test + void shouldRejectWhenGlimLoanIsHardLocked() throws ServletException, IOException { + MockHttpServletRequest request = mock(MockHttpServletRequest.class); + MockHttpServletResponse response = mock(MockHttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + PrintWriter writer = mock(PrintWriter.class); + GroupLoanIndividualMonitoringAccount glimAccount = mock(GroupLoanIndividualMonitoringAccount.class); + Loan loan = mock(Loan.class); + Long loanId = 2L; + + given(request.getPathInfo()).willReturn("/loans/glimAccount/2"); + given(request.getMethod()).willReturn(HTTPMethods.POST.value()); + given(glimAccountInfoRepository.findOneByIsAcceptingChildAndApplicationId(true, BigDecimal.valueOf(2))).willReturn(glimAccount); + given(glimAccount.getChildLoan()).willReturn(Collections.singleton(loan)); + given(loan.getId()).willReturn(loanId); + given(loanAccountLockService.isLoanHardLocked(loanId)).willReturn(true); + given(response.getWriter()).willReturn(writer); + + testObj.doFilterInternal(request, response, filterChain); + verify(response, times(1)).setStatus(HttpStatus.SC_CONFLICT); + } +}