Delinquency classification as a Loan COB business step

This commit is contained in:
Jose Alberto Hernandez
2022-09-07 16:30:22 +02:00
committed by Arnold Gálovics
parent 90896a6467
commit f26e14ae07
10 changed files with 284 additions and 23 deletions
@@ -0,0 +1,62 @@
/**
* 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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.jetbrains.annotations.NotNull;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.StepExecutionListener;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.context.SecurityContextHolder;
@Slf4j
@RequiredArgsConstructor
public class InitialisationTasklet implements Tasklet, StepExecutionListener {
private final AppUserRepositoryWrapper userRepository;
private StepExecution stepExecution;
@Override
public RepeatStatus execute(@NotNull StepContribution contribution, @NotNull ChunkContext chunkContext) throws Exception {
AppUser user = userRepository.fetchSystemUser();
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, user.getPassword(),
new NullAuthoritiesMapper().mapAuthorities(user.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(auth);
return RepeatStatus.FINISHED;
}
@Override
public void beforeStep(@NotNull StepExecution stepExecution) {
this.stepExecution = stepExecution;
}
@Override
public ExitStatus afterStep(@NotNull StepExecution stepExecution) {
return ExitStatus.COMPLETED;
}
}
@@ -25,10 +25,13 @@ import org.apache.fineract.cob.COBPropertyService;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.FlowBuilder;
import org.springframework.batch.core.job.flow.Flow;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.integration.partition.RemotePartitioningWorkerStepBuilderFactory;
import org.springframework.batch.item.ItemProcessor;
@@ -57,19 +60,42 @@ public class LoanCOBWorkerConfiguration {
private QueueChannel inboundRequests;
@Autowired
private COBBusinessStepService cobBusinessStepService;
@Autowired
private AppUserRepositoryWrapper userRepository;
@Bean(name = "Loan COB worker")
public Step loanCOBWorkerStep() {
@Bean
public Step loanBusinessStep() {
return stepBuilderFactory.get("Loan COB worker").inputChannel(inboundRequests)
.<Loan, Loan>chunk(cobPropertyService.getChunkSize(JobName.LOAN_COB.name())).reader(itemReader(null))
.processor(itemProcessor()).writer(itemWriter()).build();
}
@Bean(name = "Loan COB worker")
public Step loanCOBWorkerStep() {
return stepBuilderFactory.get("Loan COB worker - Step").inputChannel(inboundRequests).flow(flow()).build();
}
@Bean
public Job loanCOBWorkerJob() {
return jobBuilderFactory.get("Loan COB worker").start(loanCOBWorkerStep()).incrementer(new RunIdIncrementer()).build();
}
@Bean
public Flow flow() {
return new FlowBuilder<Flow>("cobFlow").start(initialisationStep()).next(loanBusinessStep()).next(loanBusinessStep()).build();
}
@Bean
public Step initialisationStep() {
return stepBuilderFactory.get("Initalisation - Step").inputChannel(inboundRequests).tasklet(initialiseContext()).build();
}
@Bean
@StepScope
public InitialisationTasklet initialiseContext() {
return new InitialisationTasklet(userRepository);
}
@Bean
@StepScope
public ItemReader<Loan> itemReader(@Value("#{stepExecutionContext['loanIds']}") List<Integer> data) {
@@ -0,0 +1,52 @@
/**
* 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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class SetLoanDelinquencyTagsBusinessStep implements LoanCOBBusinessStep {
private final LoanAccountDomainService loanAccountDomainService;
@Override
public Loan execute(Loan loan) {
log.debug("Set Loan Delinquency Tags Business Step {}", loan.getId());
loanAccountDomainService.setLoanDelinquencyTag(loan, DateUtils.getBusinessLocalDate());
return loan;
}
@Override
public String getEnumStyledName() {
return "LOAN_DELINQUENCY_CLASSIFICATION";
}
@Override
public String getHumanReadableName() {
return "Loan Delinquency Classification";
}
}
@@ -30,8 +30,6 @@ public interface DelinquencyWritePlatformService {
CommandProcessingResult deleteDelinquencyRange(Long delinquencyRangeId, JsonCommand command);
CommandProcessingResult setLoanDelinquencyTag(Long loanId, Long delinquencyRangeId, JsonCommand command);
CommandProcessingResult createDelinquencyBucket(JsonCommand command);
CommandProcessingResult updateDelinquencyBucket(Long delinquencyBucketId, JsonCommand command);
@@ -104,12 +104,6 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(delinquencyRange.getId()).build();
}
@Override
public CommandProcessingResult setLoanDelinquencyTag(Long loanId, Long delinquencyRangeId, JsonCommand command) {
setLoanDelinquencyTag(loanId, delinquencyRangeId);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId).build();
}
@Override
public CommandProcessingResult createDelinquencyBucket(JsonCommand command) {
DelinquencyBucketData data = dataValidatorBucket.validateAndParseUpdate(command);
@@ -328,11 +322,6 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
return changes;
}
public Map<String, Object> setLoanDelinquencyTag(Long loanId, Long delinquencyRangeId) {
final Loan loan = this.loanRepository.findOneWithNotFoundDetection(loanId);
return setLoanDelinquencyTag(loan, delinquencyRangeId);
}
public Map<String, Object> setLoanDelinquencyTag(Loan loan, Long delinquencyRangeId) {
Map<String, Object> changes = new HashMap<>();
List<LoanDelinquencyTagHistory> loanDelinquencyTagHistory = new ArrayList<>();
@@ -27,7 +27,6 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
@@ -3770,7 +3769,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
throw new InvalidLoanStateTransitionException("writeoff", "cannot.be.a.future.date", errorMessage, writtenOffOnLocalDate);
}
LocalDateTime createdDate = DateUtils.getLocalDateTimeOfTenant();
loanTransaction = LoanTransaction.writeoff(this, getOffice(), writtenOffOnLocalDate, txnExternalId);
LocalDate lastTransactionDate = getLastUserTransactionDate();
if (lastTransactionDate.isAfter(writtenOffOnLocalDate)) {
@@ -6877,4 +6875,5 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
return ageOfOverdueDays;
}
}
@@ -66,4 +66,5 @@
<include file="parts/0044_table_report_query_fix.xml" relativeToChangelogFile="true"/>
<include file="parts/0045_external_event_table_data_binary.xml" relativeToChangelogFile="true"/>
<include file="parts/0046_external_event_table_schema_info.xml" relativeToChangelogFile="true"/>
<include file="parts/0047_add_loan_delinquency_tags_business_step.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="1" author="fineract">
<insert tableName="m_batch_business_steps">
<column name="job_name" value="LOAN_CLOSE_OF_BUSINESS"/>
<column name="step_name" value="LOAN_DELINQUENCY_CLASSIFICATION"/>
<column name="step_order" value="2"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -41,9 +41,10 @@ public class BusinessConfigurationApiTest {
private ResponseSpecification responseSpec;
private RequestSpecification requestSpec;
private static final String LOAN_JOB_NAME = "LOAN_CLOSE_OF_BUSINESS";
private static final String LOAN_CATEGORY_NAME = "loan";
private static final String APPLY_CHARGE_TO_OVERDUE_LOANS = "APPLY_CHARGE_TO_OVERDUE_LOANS";
public static final String LOAN_JOB_NAME = "LOAN_CLOSE_OF_BUSINESS";
public static final String LOAN_CATEGORY_NAME = "loan";
public static final String APPLY_CHARGE_TO_OVERDUE_LOANS = "APPLY_CHARGE_TO_OVERDUE_LOANS";
public static final String LOAN_DELINQUENCY_CLASSIFICATION = "LOAN_DELINQUENCY_CLASSIFICATION";
@BeforeEach
public void setup() {
@@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
@@ -46,8 +47,10 @@ import org.apache.fineract.client.models.PostDelinquencyRangeResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PutDelinquencyBucketResponse;
import org.apache.fineract.client.models.PutDelinquencyRangeResponse;
import org.apache.fineract.cob.data.JobBusinessStepConfigData;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.integrationtests.common.BusinessDateHelper;
import org.apache.fineract.integrationtests.common.BusinessStepConfigurationHelper;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
import org.apache.fineract.integrationtests.common.SchedulerJobHelper;
@@ -237,6 +240,96 @@ public class DelinquencyBucketsIntegrationTest {
log.info("Current date {}", businessDate);
BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, businessDate);
final LoanTransactionHelper loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec);
final SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(requestSpec);
ArrayList<Integer> rangeIds = new ArrayList<>();
String jsonRange = DelinquencyRangesHelper.getAsJSON(1, 3);
PostDelinquencyRangeResponse delinquencyRangeResponse = DelinquencyRangesHelper.createDelinquencyRange(requestSpec, responseSpec,
jsonRange);
rangeIds.add(delinquencyRangeResponse.getResourceId());
jsonRange = DelinquencyRangesHelper.getAsJSON(4, 60);
// Create
delinquencyRangeResponse = DelinquencyRangesHelper.createDelinquencyRange(requestSpec, responseSpec, jsonRange);
rangeIds.add(delinquencyRangeResponse.getResourceId());
final GetDelinquencyRangesResponse range = DelinquencyRangesHelper.getDelinquencyRange(requestSpec, responseSpec,
delinquencyRangeResponse.getResourceId());
final String classificationExpected = range.getClassification();
log.info("Expected Delinquency Range classification {}", classificationExpected);
String jsonBucket = DelinquencyBucketsHelper.getAsJSON(rangeIds);
PostDelinquencyBucketResponse delinquencyBucketResponse = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec,
responseSpec, jsonBucket);
final GetDelinquencyBucketsResponse delinquencyBucket = DelinquencyBucketsHelper.getDelinquencyBucket(requestSpec, responseSpec,
delinquencyBucketResponse.getResourceId());
// Client and Loan account creation
final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2012");
final GetLoanProductsProductIdResponse getLoanProductsProductResponse = createLoanProduct(loanTransactionHelper,
delinquencyBucket.getId());
assertNotNull(getLoanProductsProductResponse);
log.info("Loan Product Bucket Name: {}", getLoanProductsProductResponse.getDelinquencyBucket().getName());
assertEquals(getLoanProductsProductResponse.getDelinquencyBucket().getName(), delinquencyBucket.getName());
final LocalDate todaysDate = Utils.getLocalDateOfTenant();
// Older date to have more than one overdue installment
final LocalDate transactionDate = todaysDate.minusDays(65);
String operationDate = Utils.dateFormatter.format(transactionDate);
// Create Loan Account
final Integer loanId = createLoanAccount(loanTransactionHelper, clientId.toString(),
getLoanProductsProductResponse.getId().toString(), operationDate);
// Run first time the Job
final String jobName = "Loan Delinquency Classification";
schedulerJobHelper.executeAndAwaitJob(jobName);
// Get loan details expecting to have not a delinquency classification
GetLoansLoanIdResponse getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
final GetDelinquencyRangesResponse firstTestCase = getLoansLoanIdResponse.getDelinquencyRange();
log.info("Loan Delinquency Range is null {}", (firstTestCase == null));
GetLoansLoanIdRepaymentSchedule getLoanRepaymentSchedule = getLoansLoanIdResponse.getRepaymentSchedule();
if (getLoanRepaymentSchedule != null) {
log.info("Loan with {} periods", getLoanRepaymentSchedule.getPeriods().size());
for (GetLoansLoanIdRepaymentPeriod period : getLoanRepaymentSchedule.getPeriods()) {
log.info("Period number {} for due date {} and outstanding {}", period.getPeriod(), period.getDueDate(),
period.getTotalOutstandingForPeriod());
}
}
// Move the Business date to get older the loan and to have an overdue loan
businessDate = businessDate.plusDays(40);
log.info("Current date {}", businessDate);
BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, businessDate);
// Run Second time the Job
schedulerJobHelper.executeAndAwaitJob(jobName);
// Get loan details expecting to have a delinquency classification
getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
final GetDelinquencyRangesResponse secondTestCase = getLoansLoanIdResponse.getDelinquencyRange();
log.info("Loan Delinquency Range is {}", secondTestCase.getClassification());
// Then
assertNotNull(delinquencyBucketResponse);
assertNotNull(getLoanProductsProductResponse);
assertNull(firstTestCase);
assertEquals(getLoanProductsProductResponse.getDelinquencyBucket().getName(), delinquencyBucket.getName());
assertNotNull(secondTestCase);
assertEquals(secondTestCase.getClassification(), classificationExpected);
GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE);
}
@Test
public void testLoanClassificationStepAsPartOfCOB() {
GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.TRUE);
LocalDate businessDate = Utils.getLocalDateOfTenant();
businessDate = businessDate.minusDays(57);
log.info("Current date {}", businessDate);
BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, businessDate);
// Given
final LoanTransactionHelper loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec);
final SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(requestSpec);
@@ -279,9 +372,17 @@ public class DelinquencyBucketsIntegrationTest {
final Integer loanId = createLoanAccount(loanTransactionHelper, clientId.toString(),
getLoanProductsProductResponse.getId().toString(), operationDate);
// When
// Run first time the Job
final String jobName = "Loan Delinquency Classification";
// COB Step Validation
final JobBusinessStepConfigData jobBusinessStepConfigData = BusinessStepConfigurationHelper
.getConfiguredBusinessStepsByJobName(requestSpec, responseSpec, BusinessConfigurationApiTest.LOAN_JOB_NAME);
assertNotNull(jobBusinessStepConfigData);
assertEquals(BusinessConfigurationApiTest.LOAN_JOB_NAME, jobBusinessStepConfigData.getJobName());
assertTrue(jobBusinessStepConfigData.getBusinessSteps().size() > 0);
assertTrue(jobBusinessStepConfigData.getBusinessSteps().stream()
.anyMatch(businessStep -> BusinessConfigurationApiTest.LOAN_DELINQUENCY_CLASSIFICATION.equals(businessStep.getStepName())));
// Run first time the Loan COB Job
final String jobName = "Loan COB";
schedulerJobHelper.executeAndAwaitJob(jobName);
// Get loan details expecting to have not a delinquency classification