FINERACT-2421: Allow ProgressiveLoanInterestScheduleModel recalculation

Co-authored-by: Soma Sörös <soma.soros@dpc.hu>
This commit is contained in:
Adam Saghy
2026-02-13 14:58:37 +01:00
co-authored by Soma Sörös
parent a8fb5e7826
commit 6269cbbb79
26 changed files with 563 additions and 15 deletions
@@ -32,6 +32,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.cob.COBBusinessStepService;
import org.apache.fineract.cob.data.BusinessStepNameAndOrder;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.AfterStep;
@@ -44,6 +46,7 @@ import org.springframework.lang.NonNull;
public abstract class AbstractLoanItemProcessor implements ItemProcessor<Loan, Loan> {
private final COBBusinessStepService cobBusinessStepService;
private final ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService;
@Setter(AccessLevel.PROTECTED)
private ExecutionContext executionContext;
@@ -51,18 +54,26 @@ public abstract class AbstractLoanItemProcessor implements ItemProcessor<Loan, L
@SuppressWarnings({ "unchecked" })
@Override
public Loan process(@NonNull Loan item) throws Exception {
public Loan process(@NonNull Loan loan) throws Exception {
if (needToRebuildModel(loan)) {
progressiveLoanModelProcessingService.recalculateModelAndSave(loan.getId());
}
Set<BusinessStepNameAndOrder> businessSteps = (Set<BusinessStepNameAndOrder>) executionContext.get(LoanCOBConstant.BUSINESS_STEPS);
if (businessSteps == null) {
throw new IllegalStateException("No business steps found in the execution context");
}
TreeMap<Long, String> businessStepMap = getBusinessStepMap(businessSteps);
Loan alreadyProcessedLoan = cobBusinessStepService.run(businessStepMap, item);
Loan alreadyProcessedLoan = cobBusinessStepService.run(businessStepMap, loan);
alreadyProcessedLoan.setLastClosedBusinessDate(businessDate);
return alreadyProcessedLoan;
}
private boolean needToRebuildModel(Loan loan) {
return loan.isProgressiveSchedule() && !progressiveLoanModelProcessingService.hasValidModel(loan.getId(),
ProgressiveLoanInterestScheduleModel.getModelVersion());
}
private TreeMap<Long, String> getBusinessStepMap(Set<BusinessStepNameAndOrder> businessSteps) {
Map<Long, String> businessStepMap = businessSteps.stream()
.collect(Collectors.toMap(BusinessStepNameAndOrder::getStepOrder, BusinessStepNameAndOrder::getStepName));
@@ -19,13 +19,15 @@
package org.apache.fineract.cob.loan;
import org.apache.fineract.cob.COBBusinessStepService;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.BeforeStep;
public class InlineCOBLoanItemProcessor extends AbstractLoanItemProcessor {
public InlineCOBLoanItemProcessor(COBBusinessStepService cobBusinessStepService) {
super(cobBusinessStepService);
public InlineCOBLoanItemProcessor(COBBusinessStepService cobBusinessStepService,
ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService) {
super(cobBusinessStepService, progressiveLoanModelProcessingService);
}
@BeforeStep
@@ -28,6 +28,7 @@ import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.springbatch.PropertyService;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
@@ -80,6 +81,9 @@ public class LoanCOBWorkerConfiguration {
@Autowired
private LoanLockingService loanLockingService;
@Autowired
private ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService;
@Bean(name = LoanCOBConstant.LOAN_COB_WORKER_STEP)
public Step loanCOBWorkerStep(Flow cobFlow) {
return stepBuilderFactory.get("Loan COB worker - Step").inputChannel(inboundRequests).flow(cobFlow).build();
@@ -178,7 +182,7 @@ public class LoanCOBWorkerConfiguration {
@Bean
@StepScope
public LoanItemProcessor cobWorkerItemProcessor() {
return new LoanItemProcessor(cobBusinessStepService);
return new LoanItemProcessor(cobBusinessStepService, progressiveLoanModelProcessingService);
}
@Bean
@@ -28,6 +28,7 @@ import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.springbatch.PropertyService;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobScope;
@@ -65,9 +66,10 @@ public class LoanInlineCOBConfig {
private CustomJobParameterRepository customJobParameterRepository;
@Autowired
private CustomJobParameterResolver customJobParameterResolver;
@Autowired
private LoanLockingService loanLockingService;
@Autowired
private ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService;
@Bean
public InlineLoanCOBBuildExecutionContextTasklet inlineLoanCOBBuildExecutionContextTasklet() {
@@ -106,7 +108,7 @@ public class LoanInlineCOBConfig {
@JobScope
@Bean
public InlineCOBLoanItemProcessor inlineCobWorkerItemProcessor() {
return new InlineCOBLoanItemProcessor(cobBusinessStepService);
return new InlineCOBLoanItemProcessor(cobBusinessStepService, progressiveLoanModelProcessingService);
}
@Bean
@@ -19,13 +19,15 @@
package org.apache.fineract.cob.loan;
import org.apache.fineract.cob.COBBusinessStepService;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.BeforeStep;
public class LoanItemProcessor extends AbstractLoanItemProcessor {
public LoanItemProcessor(COBBusinessStepService cobBusinessStepService) {
super(cobBusinessStepService);
public LoanItemProcessor(COBBusinessStepService cobBusinessStepService,
ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService) {
super(cobBusinessStepService, progressiveLoanModelProcessingService);
}
@BeforeStep
@@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.service.MDCWrapper;
import org.apache.fineract.infrastructure.instancemode.filter.FineractInstanceModeApiFilter;
import org.apache.fineract.infrastructure.jobs.filter.LoanCOBApiFilter;
import org.apache.fineract.infrastructure.jobs.filter.LoanCOBFilterHelper;
import org.apache.fineract.infrastructure.jobs.filter.ProgressiveLoanModelCheckerFilter;
import org.apache.fineract.infrastructure.security.data.PlatformRequestLog;
import org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter;
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
@@ -113,6 +114,8 @@ public class SecurityConfig {
private LoanCOBFilterHelper loanCOBFilterHelper;
@Autowired
private IdempotencyStoreHelper idempotencyStoreHelper;
@Autowired
ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@@ -216,8 +219,10 @@ public class SecurityConfig {
if (loanCOBFilterHelper != null) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
LoanCOBApiFilter.class);
http.addFilterBefore(progressiveLoanModelCheckerFilter, LoanCOBApiFilter.class);
} else {
http.addFilterAfter(idempotencyStoreFilter(), FineractInstanceModeApiFilter.class);
http.addFilterAfter(progressiveLoanModelCheckerFilter, FineractInstanceModeApiFilter.class);
}
if (fineractProperties.getIpTracking().isEnabled()) {
http.addFilterAfter(callerIpTrackingFilter(), RequestResponseFilter.class);
@@ -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.jobs.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.http.BodyCachingHttpServletRequestWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
public class ProgressiveLoanModelCheckerFilter extends OncePerRequestFilter {
private final LoanRepository loanRepository;
private final ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService;
private final ProgressiveLoanModelCheckerHelper helper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request = new BodyCachingHttpServletRequestWrapper(request);
if (!helper.isOnApiList((BodyCachingHttpServletRequestWrapper) request)) {
proceed(filterChain, request, response);
} else {
List<Long> loanIds = helper.calculateRelevantLoanIds((BodyCachingHttpServletRequestWrapper) request);
if (!loanIds.isEmpty()) {
loanIds.forEach(loanId -> {
if (isProgressiveLoan(loanId) && allowedLoanStatuses(loanId) && !hasValidModel(loanId)) {
progressiveLoanModelProcessingService.recalculateModelAndSave(loanId);
}
});
}
proceed(filterChain, request, response);
}
}
private boolean isProgressiveLoan(Long loanId) {
return loanRepository.isProgressiveLoan(loanId);
}
private boolean allowedLoanStatuses(Long loanId) {
return progressiveLoanModelProcessingService.allowedLoanStatuses(loanId);
}
private boolean hasValidModel(Long loanId) {
return progressiveLoanModelProcessingService.hasValidModel(loanId, ProgressiveLoanInterestScheduleModel.getModelVersion());
}
private void proceed(FilterChain filterChain, HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,217 @@
/**
* 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.apache.fineract.batch.command.CommandStrategyUtils.isRelativeUrlVersioned;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.batch.domain.BatchRequest;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.http.BodyCachingHttpServletRequestWrapper;
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.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanRescheduleRequestRepository;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
public class ProgressiveLoanModelCheckerHelper implements InitializingBean {
private final GLIMAccountInfoRepository glimAccountInfoRepository;
private final LoanRepository loanRepository;
private final LoanRescheduleRequestRepository loanRescheduleRequestRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final List<HttpMethod> HTTP_METHODS = List.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE);
public static final Pattern IGNORE_LOAN_PATH_PATTERN = Pattern.compile("/v[1-9][0-9]*/loans/catch-up");
public static final Pattern LOAN_PATH_PATTERN = Pattern.compile("/v[1-9][0-9]*/(?:reschedule)?loans/(?:external-id/)?([^/?]+).*");
public static final Pattern LOAN_GLIMACCOUNT_PATH_PATTERN = Pattern.compile("/v[1-9][0-9]*/loans/glimAccount/(\\d+).*");
private static final Predicate<String> URL_FUNCTION = s -> LOAN_PATH_PATTERN.matcher(s).find()
|| LOAN_GLIMACCOUNT_PATH_PATTERN.matcher(s).find();
private Long getLoanId(boolean isGlim, String pathInfo) {
if (!isGlim) {
String id = LOAN_PATH_PATTERN.matcher(pathInfo).replaceAll("$1");
if (isExternal(pathInfo)) {
String externalId = id;
return loanRepository.findIdByExternalId(new ExternalId(externalId));
} else if (isRescheduleLoans(pathInfo)) {
return loanRescheduleRequestRepository.getLoanIdByRescheduleRequestId(Long.valueOf(id)).orElse(null);
} else if (StringUtils.isNumeric(id)) {
return Long.valueOf(id);
} else {
return null;
}
} else {
return Long.valueOf(LOAN_GLIMACCOUNT_PATH_PATTERN.matcher(pathInfo).replaceAll("$1"));
}
}
private boolean isExternal(String pathInfo) {
return LOAN_PATH_PATTERN.matcher(pathInfo).matches() && pathInfo.contains("external-id");
}
private boolean isRescheduleLoans(String pathInfo) {
return LOAN_PATH_PATTERN.matcher(pathInfo).matches() && pathInfo.contains("/v1/rescheduleloans/");
}
public boolean isOnApiList(BodyCachingHttpServletRequestWrapper request) throws IOException {
String pathInfo = request.getPathInfo();
String method = request.getMethod();
if (StringUtils.isBlank(pathInfo)) {
return false;
}
if (isBatchApi(pathInfo)) {
return isBatchApiMatching(request);
} else {
return isApiMatching(method, pathInfo);
}
}
private boolean isBatchApiMatching(BodyCachingHttpServletRequestWrapper request) throws IOException {
for (BatchRequest batchRequest : getBatchRequests(request)) {
String method = batchRequest.getMethod();
String pathInfo = batchRequest.getRelativeUrl();
if (isApiMatching(method, pathInfo)) {
return true;
}
}
return false;
}
private List<BatchRequest> getBatchRequests(BodyCachingHttpServletRequestWrapper request) throws IOException {
List<BatchRequest> batchRequests = objectMapper.readValue(request.getInputStream(), new TypeReference<>() {});
// since we read body, we have to reset so the upcoming readings are successful
request.resetStream();
for (BatchRequest batchRequest : batchRequests) {
String pathInfo = "/" + batchRequest.getRelativeUrl();
if (!isRelativeUrlVersioned(batchRequest.getRelativeUrl())) {
pathInfo = "/v1/" + batchRequest.getRelativeUrl();
}
batchRequest.setRelativeUrl(pathInfo);
}
return batchRequests;
}
private boolean isApiMatching(String method, String pathInfo) {
return HTTP_METHODS.contains(HttpMethod.valueOf(method)) && !IGNORE_LOAN_PATH_PATTERN.matcher(pathInfo).find()
&& URL_FUNCTION.test(pathInfo);
}
private boolean isBatchApi(String pathInfo) {
return pathInfo.startsWith("/v1/batches");
}
private boolean isGlim(String pathInfo) {
return LOAN_GLIMACCOUNT_PATH_PATTERN.matcher(pathInfo).matches();
}
private List<Long> getGlimChildLoanIds(Long loanIdFromRequest) {
GroupLoanIndividualMonitoringAccount glimAccount = glimAccountInfoRepository.findOneByIsAcceptingChildAndApplicationId(true,
BigDecimal.valueOf(loanIdFromRequest));
if (glimAccount != null) {
return glimAccount.getChildLoan().stream().map(Loan::getId).toList();
} else {
return Collections.emptyList();
}
}
public List<Long> calculateRelevantLoanIds(BodyCachingHttpServletRequestWrapper request) throws IOException {
String pathInfo = request.getPathInfo();
if (isBatchApi(pathInfo)) {
return getLoanIdsFromBatchApi(request);
} else {
return getLoanIdsFromApi(pathInfo);
}
}
private List<Long> getLoanIdsFromBatchApi(BodyCachingHttpServletRequestWrapper request) throws IOException {
List<Long> loanIds = new ArrayList<>();
for (BatchRequest batchRequest : getBatchRequests(request)) {
// check the URL for Loan related ID
String relativeUrl = batchRequest.getRelativeUrl();
if (!relativeUrl.contains("$.resourceId")) {
// if resourceId reference is used, we simply don't know the resourceId without executing the requests
// first, so skipping it
loanIds.addAll(getLoanIdsFromApi(relativeUrl));
}
// check the body for Loan ID
Long loanId = getTopLevelLoanIdFromBatchRequest(batchRequest);
if (loanId != null) {
loanIds.add(loanId);
}
}
return loanIds;
}
private Long getTopLevelLoanIdFromBatchRequest(BatchRequest batchRequest) throws JsonProcessingException {
String body = batchRequest.getBody();
if (StringUtils.isNotBlank(body)) {
JsonNode jsonNode = objectMapper.readTree(body);
if (jsonNode.has("loanId")) {
return jsonNode.get("loanId").asLong();
}
}
return null;
}
private List<Long> getLoanIdsFromApi(String pathInfo) {
return getLoanIdList(pathInfo);
}
private List<Long> getLoanIdList(String pathInfo) {
boolean isGlim = isGlim(pathInfo);
Long loanIdFromRequest = getLoanId(isGlim, pathInfo);
if (loanIdFromRequest == null) {
return Collections.emptyList();
}
if (isGlim) {
return getGlimChildLoanIds(loanIdFromRequest);
} else {
return Collections.singletonList(loanIdFromRequest);
}
}
@Override
public void afterPropertiesSet() throws Exception {
objectMapper.configure(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature(), true);
}
}
@@ -35,6 +35,7 @@ import org.apache.fineract.infrastructure.core.service.MDCWrapper;
import org.apache.fineract.infrastructure.instancemode.filter.FineractInstanceModeApiFilter;
import org.apache.fineract.infrastructure.jobs.filter.LoanCOBApiFilter;
import org.apache.fineract.infrastructure.jobs.filter.LoanCOBFilterHelper;
import org.apache.fineract.infrastructure.jobs.filter.ProgressiveLoanModelCheckerFilter;
import org.apache.fineract.infrastructure.security.converter.FineractJwtAuthenticationTokenConverter;
import org.apache.fineract.infrastructure.security.data.TenantAuthenticationDetails;
import org.apache.fineract.infrastructure.security.filter.BusinessDateFilter;
@@ -118,6 +119,9 @@ public class AuthorizationServerConfig {
@Autowired
private BusinessDateReadPlatformService businessDateReadPlatformService;
@Autowired
ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
@Bean
@Order(1)
public SecurityFilterChain publicEndpoints(HttpSecurity http) throws Exception {
@@ -173,9 +177,12 @@ public class AuthorizationServerConfig {
if (!Objects.isNull(loanCOBFilterHelper)) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class) //
.addFilterAfter(idempotencyStoreFilter(), LoanCOBApiFilter.class); //
http.addFilterBefore(progressiveLoanModelCheckerFilter, LoanCOBApiFilter.class);
} else {
http.addFilterAfter(idempotencyStoreFilter(), FineractInstanceModeApiFilter.class); //
http.addFilterAfter(progressiveLoanModelCheckerFilter, FineractInstanceModeApiFilter.class);
}
if (fineractProperties.getIpTracking().isEnabled()) {
http.addFilterAfter(callerIpTrackingFilter(), RequestResponseFilter.class);
}
@@ -33,6 +33,7 @@ import java.util.Collections;
import java.util.TreeMap;
import org.apache.fineract.cob.COBBusinessStepService;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanModelProcessingService;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.item.ExecutionContext;
@@ -41,8 +42,9 @@ import org.springframework.batch.item.ExecutionContext;
public class LoanItemProcessorStepDefinitions implements En {
private COBBusinessStepService cobBusinessStepService = mock(COBBusinessStepService.class);
private ProgressiveLoanModelProcessingService progressiveLoanModelProcessingService = mock(ProgressiveLoanModelProcessingService.class);
private LoanItemProcessor loanItemProcessor = new LoanItemProcessor(cobBusinessStepService);
private LoanItemProcessor loanItemProcessor = new LoanItemProcessor(cobBusinessStepService, progressiveLoanModelProcessingService);
private Loan loan = mock(Loan.class);