FINERACT-1992 Delinquency Pause API Changes

This commit is contained in:
Peter Bagrij
2023-11-14 19:33:57 +01:00
committed by Adam Saghy
parent 71f9f57ba0
commit 42d4198641
23 changed files with 1403 additions and 23 deletions
@@ -3653,4 +3653,13 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/transactions?command=downPayment";
return this;
}
public CommandWrapperBuilder createDelinquencyAction(final Long loanId) {
this.actionName = "CREATE";
this.entityName = "DELINQUENCY_ACTION";
this.entityId = null;
this.loanId = loanId;
this.href = "/loans/" + loanId + "/delinquency-action";
return this;
}
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.delinquency.api;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public final class DelinquencyApiResourceSwagger {
@@ -150,4 +151,55 @@ public final class DelinquencyApiResourceSwagger {
public LocalDate liftedOnDate;
}
@Schema(description = "GetDelinquencyActionsResponse")
public static final class GetDelinquencyActionsResponse {
private GetDelinquencyActionsResponse() {}
@Schema(example = "1")
public Long id;
@Schema(example = "pause")
public String action;
@Schema(example = "2013,1,2")
public LocalDate startDate;
@Schema(example = "2013,2,20")
public LocalDate endDate;
@Schema(example = "1")
public Long createdById;
@Schema(example = "1359463135000")
public OffsetDateTime createdOn;
@Schema(example = "1")
public Long updatedById;
@Schema(example = "1359463135000")
public OffsetDateTime lastModifiedOn;
}
@Schema(description = "PostLoansDelinquencyActionRequest")
public static final class PostLoansDelinquencyActionRequest {
@Schema(example = "pause")
public String action;
@Schema(example = "2013-01-02")
public String startDate;
@Schema(example = "2013-02-20")
public String endDate;
@Schema(example = "yyyy-MM-dd")
public String dateFormat;
@Schema(example = "en")
public String locale;
}
@Schema(description = "PostLoansDelinquencyActionResponse")
public static final class PostLoansDelinquencyActionResponse {
@Schema(example = "1")
public Long officeId;
@Schema(example = "1")
public Long clientId;
@Schema(example = "1")
public Long resourceId;
}
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.delinquency.domain;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -35,4 +37,7 @@ public interface LoanDelinquencyActionRepository
+ " da.loan.id = :loan_id order by da.createdDate desc")
Page<LoanDelinquencyAction> getEffectiveDelinquencyActionForLoan(@Param("loan_id") Long loan_id,
@Param("business_date") LocalDate business_date, Pageable page);
List<LoanDelinquencyAction> findByLoanOrderById(Loan loan);
}
@@ -0,0 +1,42 @@
/**
* 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.delinquency.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@CommandType(entity = "DELINQUENCY_ACTION", action = "CREATE")
public class CreateDelinquencyActionCommandHandler implements NewCommandSourceHandler {
private final DelinquencyWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createDelinquencyAction(command.getLoanId(), command);
}
}
@@ -23,6 +23,7 @@ import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData;
import org.apache.fineract.portfolio.delinquency.data.DelinquencyRangeData;
import org.apache.fineract.portfolio.delinquency.data.LoanDelinquencyTagHistoryData;
import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
public interface DelinquencyReadPlatformService {
@@ -43,4 +44,6 @@ public interface DelinquencyReadPlatformService {
Collection<LoanInstallmentDelinquencyTagData> retrieveLoanInstallmentsCurrentDelinquencyTag(Long loanId);
Collection<LoanDelinquencyAction> retrieveLoanDelinquencyActions(Long loanId);
}
@@ -18,24 +18,37 @@
*/
package org.apache.fineract.portfolio.delinquency.service;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData;
import org.apache.fineract.portfolio.delinquency.data.DelinquencyRangeData;
import org.apache.fineract.portfolio.delinquency.data.LoanDelinquencyTagHistoryData;
import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRange;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistory;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData;
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
@@ -55,6 +68,7 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
private final LoanRepository loanRepository;
private final LoanDelinquencyDomainService loanDelinquencyDomainService;
private final LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag;
private final LoanDelinquencyActionRepository loanDelinquencyActionRepository;
@Override
public Collection<DelinquencyRangeData> retrieveAllDelinquencyRanges() {
@@ -124,14 +138,71 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
collectionData.setLastRepaymentDate(lastRepaymentTransaction.getTransactionDate());
collectionData.setLastRepaymentAmount(lastRepaymentTransaction.getAmount());
}
enrichWithDelinquencyPausePeriodInfo(collectionData, retrieveLoanDelinquencyActions(loanId),
ThreadLocalContextUtil.getBusinessDate());
}
return collectionData;
}
void enrichWithDelinquencyPausePeriodInfo(CollectionData collectionData, Collection<LoanDelinquencyAction> delinquencyActions,
LocalDate businessDate) {
// partition them based on type
Map<DelinquencyAction, List<LoanDelinquencyAction>> partitioned = delinquencyActions.stream()
.collect(Collectors.groupingBy(LoanDelinquencyAction::getAction));
// add the possible resumes to it to create the effective pause periods
if (partitioned.containsKey(DelinquencyAction.PAUSE)) {
List<LoanDelinquencyActionData> effective = new ArrayList<>();
List<LoanDelinquencyAction> pauses = partitioned.get(DelinquencyAction.PAUSE);
for (LoanDelinquencyAction loanDelinquencyAction : pauses) {
Optional<LoanDelinquencyAction> resume = findMatchingResume(loanDelinquencyAction, partitioned.get(RESUME));
LoanDelinquencyActionData loanDelinquencyActionData = new LoanDelinquencyActionData(loanDelinquencyAction);
resume.ifPresent(r -> loanDelinquencyActionData.setEndDate(r.getStartDate()));
effective.add(loanDelinquencyActionData);
}
// order them by start date, filter out the future items
Optional<LoanDelinquencyActionData> last = effective.stream()
.sorted(Comparator.comparing(LoanDelinquencyActionData::getStartDate))
.filter(e -> !DateUtils.isAfter(e.getStartDate(), businessDate)) // keep only the present and the
// past ones
.reduce((first, second) -> second);
// enrich collectionData
last.ifPresent(action -> {
collectionData
.setDelinquencyCalculationPaused(!action.startDate.isAfter(businessDate) && !businessDate.isAfter(action.endDate));
collectionData.setDelinquencyPausePeriodStartDate(action.getStartDate());
collectionData.setDelinquencyPausePeriodEndDate(action.getEndDate());
});
}
}
private Optional<LoanDelinquencyAction> findMatchingResume(LoanDelinquencyAction pause, List<LoanDelinquencyAction> resumes) {
if (resumes != null && resumes.size() > 0) {
for (LoanDelinquencyAction resume : resumes) {
if (!pause.getStartDate().isAfter(resume.getStartDate()) && !resume.getStartDate().isAfter(pause.getEndDate())) {
return Optional.of(resume);
}
}
}
return Optional.empty();
}
@Override
public Collection<LoanInstallmentDelinquencyTagData> retrieveLoanInstallmentsCurrentDelinquencyTag(Long loanId) {
return repositoryLoanInstallmentDelinquencyTag.findInstallmentDelinquencyTags(loanId);
}
@Override
public Collection<LoanDelinquencyAction> retrieveLoanDelinquencyActions(Long loanId) {
final Optional<Loan> optLoan = this.loanRepository.findById(loanId);
if (optLoan.isPresent()) {
return loanDelinquencyActionRepository.findByLoanOrderById(optLoan.get());
}
return List.of();
}
}
@@ -47,4 +47,6 @@ public interface DelinquencyWritePlatformService {
void applyDelinquencyTagToLoan(LoanScheduleDelinquencyData loanDelinquencyData);
CommandProcessingResult createDelinquencyAction(Long loanId, JsonCommand command);
}
@@ -44,12 +44,15 @@ import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketMapping
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRange;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistory;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTag;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.exception.DelinquencyBucketAgesOverlapedException;
import org.apache.fineract.portfolio.delinquency.exception.DelinquencyRangeInvalidAgesException;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParseAndValidator;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyBucketParseAndValidator;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyRangeParseAndValidator;
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
@@ -76,6 +79,8 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanDelinquencyDomainService loanDelinquencyDomainService;
private final LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository;
private final LoanDelinquencyActionRepository loanDelinquencyActionRepository;
private final DelinquencyActionParseAndValidator delinquencyActionParseAndValidator;
@Override
public CommandProcessingResult createDelinquencyRange(JsonCommand command) {
@@ -199,6 +204,28 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
}
}
@Override
public CommandProcessingResult createDelinquencyAction(Long loanId, JsonCommand command) {
final Loan loan = this.loanRepository.findOneWithNotFoundDetection(loanId);
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<LoanDelinquencyAction> savedDelinquencyList = loanDelinquencyActionRepository.findByLoanOrderById(loan);
LoanDelinquencyAction parsedDelinquencyAction = delinquencyActionParseAndValidator.validateAndParseUpdate(command, loan,
savedDelinquencyList, businessDate);
parsedDelinquencyAction.setLoan(loan);
LoanDelinquencyAction saved = loanDelinquencyActionRepository.saveAndFlush(parsedDelinquencyAction);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
.withEntityId(saved.getId()) //
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withGroupId(loan.getGroupId()) //
.withLoanId(loanId) //
.build();
}
@Override
public void removeDelinquencyTagToLoan(final Loan loan) {
setLoanDelinquencyTag(loan, null);
@@ -22,6 +22,7 @@ import org.apache.fineract.infrastructure.event.business.service.BusinessEventNo
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketMappingsRepository;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper;
@@ -33,6 +34,7 @@ import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatfor
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformServiceImpl;
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainService;
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParseAndValidator;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyBucketParseAndValidator;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyRangeParseAndValidator;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
@@ -52,10 +54,11 @@ public class DelinquencyConfiguration {
DelinquencyRangeMapper mapperRange, DelinquencyBucketMapper mapperBucket,
LoanDelinquencyTagMapper mapperLoanDelinquencyTagHistory, LoanRepository loanRepository,
LoanDelinquencyDomainService loanDelinquencyDomainService,
LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag) {
LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag,
LoanDelinquencyActionRepository loanDelinquencyActionRepository) {
return new DelinquencyReadPlatformServiceImpl(repositoryRange, repositoryBucket, repositoryLoanDelinquencyTagHistory, mapperRange,
mapperBucket, mapperLoanDelinquencyTagHistory, loanRepository, loanDelinquencyDomainService,
repositoryLoanInstallmentDelinquencyTag);
repositoryLoanInstallmentDelinquencyTag, loanDelinquencyActionRepository);
}
@Bean
@@ -66,10 +69,13 @@ public class DelinquencyConfiguration {
LoanDelinquencyTagHistoryRepository loanDelinquencyTagRepository, LoanRepositoryWrapper loanRepository,
LoanProductRepository loanProductRepository, BusinessEventNotifierService businessEventNotifierService,
LoanDelinquencyDomainService loanDelinquencyDomainService,
LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository) {
LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository,
LoanDelinquencyActionRepository loanDelinquencyActionRepository,
DelinquencyActionParseAndValidator delinquencyActionParseAndValidator) {
return new DelinquencyWritePlatformServiceImpl(dataValidatorBucket, dataValidatorRange, repositoryRange, repositoryBucket,
repositoryBucketMappings, loanDelinquencyTagRepository, loanRepository, loanProductRepository, businessEventNotifierService,
loanDelinquencyDomainService, loanInstallmentDelinquencyTagRepository);
loanDelinquencyDomainService, loanInstallmentDelinquencyTagRepository, loanDelinquencyActionRepository,
delinquencyActionParseAndValidator);
}
@Bean
@@ -0,0 +1,31 @@
/**
* 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.delinquency.validator;
public final class DelinquencyActionParameters {
private DelinquencyActionParameters() {}
public static final String ACTION = "action";
public static final String START_DATE = "startDate";
public static final String END_DATE = "endDate";
public static final String DATE_FORMAT = "dateFormat";
public static final String LOCALE = "locale";
}
@@ -0,0 +1,199 @@
/**
* 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.delinquency.validator;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
import com.google.gson.JsonElement;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.validator.ParseAndValidator;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
public class DelinquencyActionParseAndValidator extends ParseAndValidator {
private final FromJsonHelper jsonHelper;
public LoanDelinquencyAction validateAndParseUpdate(@NotNull final JsonCommand command, Loan loan,
List<LoanDelinquencyAction> savedDelinquencyActions, LocalDate businessDate) {
List<LoanDelinquencyActionData> effectiveDelinquencyList = calculateEffectiveDelinquencyList(savedDelinquencyActions);
LoanDelinquencyAction parsedDelinquencyAction = parseCommand(command);
validateLoanIsActive(loan);
if (DelinquencyAction.PAUSE.equals(parsedDelinquencyAction.getAction())) {
validatePauseStartAndEndDate(parsedDelinquencyAction, businessDate);
validatePauseShallNotOverlap(parsedDelinquencyAction, effectiveDelinquencyList);
} else if (DelinquencyAction.RESUME.equals(parsedDelinquencyAction.getAction())) {
validateResumeStartDate(parsedDelinquencyAction, businessDate);
validateResumeNoEndDate(parsedDelinquencyAction);
validateResumeShouldBeOnActivePause(parsedDelinquencyAction, effectiveDelinquencyList);
}
return parsedDelinquencyAction;
}
private List<LoanDelinquencyActionData> calculateEffectiveDelinquencyList(List<LoanDelinquencyAction> savedDelinquencyActions) {
// partition them based on type
Map<DelinquencyAction, List<LoanDelinquencyAction>> partitioned = savedDelinquencyActions.stream()
.collect(Collectors.groupingBy(LoanDelinquencyAction::getAction));
List<LoanDelinquencyActionData> effective = new ArrayList<>();
List<LoanDelinquencyAction> pauses = partitioned.get(DelinquencyAction.PAUSE);
if (pauses != null && pauses.size() > 0) {
for (LoanDelinquencyAction loanDelinquencyAction : pauses) {
Optional<LoanDelinquencyAction> resume = findMatchingResume(loanDelinquencyAction, partitioned.get(RESUME));
LoanDelinquencyActionData loanDelinquencyActionData = new LoanDelinquencyActionData(loanDelinquencyAction);
resume.ifPresent(r -> loanDelinquencyActionData.setEndDate(r.getStartDate()));
effective.add(loanDelinquencyActionData);
}
}
return effective;
}
private Optional<LoanDelinquencyAction> findMatchingResume(LoanDelinquencyAction pause, List<LoanDelinquencyAction> resumes) {
if (resumes != null && resumes.size() > 0) {
for (LoanDelinquencyAction resume : resumes) {
if (!pause.getStartDate().isAfter(resume.getStartDate()) && !resume.getStartDate().isAfter(pause.getEndDate())) {
return Optional.of(resume);
}
}
}
return Optional.empty();
}
private void validateResumeShouldBeOnActivePause(LoanDelinquencyAction parsedDelinquencyAction,
List<LoanDelinquencyActionData> savedDelinquencyActions) {
boolean match = savedDelinquencyActions.stream()
.anyMatch(lda -> !DateUtils.isBefore(parsedDelinquencyAction.getStartDate(), lda.getStartDate())
&& !DateUtils.isAfter(parsedDelinquencyAction.getStartDate(), lda.getEndDate()));
if (!match) {
raiseValidationError("loan-delinquency-action-resume-should-be-on-pause",
"Resume Delinquency Action can only be created during an active pause");
}
}
private void validateResumeNoEndDate(LoanDelinquencyAction parsedDelinquencyAction) {
if (parsedDelinquencyAction.getEndDate() != null) {
raiseValidationError("loan-delinquency-action-resume-should-have-no-end-date",
"Resume Delinquency action can not have end date");
}
}
private void validateResumeStartDate(LoanDelinquencyAction parsedDelinquencyAction, LocalDate businessDate) {
if (!parsedDelinquencyAction.getStartDate().equals(businessDate)) {
raiseValidationError("loan-delinquency-action-invalid-start-date",
"Start date of the Resume Delinquency action must be the current business date");
}
}
private void validatePauseStartAndEndDate(LoanDelinquencyAction parsedDelinquencyAction, LocalDate businessDate) {
if (parsedDelinquencyAction.getStartDate().equals(parsedDelinquencyAction.getEndDate())) {
raiseValidationError("loan-delinquency-action-invalid-start-date-and-end-date",
"Delinquency pause period must be at least one day");
}
if (businessDate.isAfter(parsedDelinquencyAction.getStartDate())) {
raiseValidationError("loan-delinquency-action-invalid-start-date", "Start date of pause period must be in the future");
}
}
private void validateLoanIsActive(Loan loan) {
if (!loan.getStatus().isActive()) {
raiseValidationError("loan-delinquency-action-invalid-loan-state", "Delinquency actions can be created only for active loans.");
}
}
private void validatePauseShallNotOverlap(LoanDelinquencyAction parsedDelinquencyAction,
List<LoanDelinquencyActionData> delinquencyActions) {
if (delinquencyActions.stream().filter(lda -> lda.getAction().equals(DelinquencyAction.PAUSE))
.anyMatch(lda -> isOverlapping(parsedDelinquencyAction, lda))) {
raiseValidationError("loan-delinquency-action-overlapping",
"Delinquency pause period cannot overlap with another pause period");
}
}
private boolean isOverlapping(LoanDelinquencyAction parsedDelinquencyAction, LoanDelinquencyActionData ldad) {
return ((!parsedDelinquencyAction.getStartDate().isAfter(ldad.getStartDate())
&& !ldad.getStartDate().isAfter(parsedDelinquencyAction.getEndDate()))
|| (!parsedDelinquencyAction.getStartDate().isAfter(ldad.getEndDate())
&& !ldad.getEndDate().isAfter(parsedDelinquencyAction.getEndDate())));
}
@org.jetbrains.annotations.NotNull
private LoanDelinquencyAction parseCommand(@org.jetbrains.annotations.NotNull JsonCommand command) {
LoanDelinquencyAction parsedDelinquencyAction = new LoanDelinquencyAction();
parsedDelinquencyAction.setAction(extractAction(command.parsedJson()));
parsedDelinquencyAction.setStartDate(extractStartDate(command.parsedJson()));
parsedDelinquencyAction.setEndDate(extractEndDate(command.parsedJson()));
return parsedDelinquencyAction;
}
private DelinquencyAction extractAction(JsonElement json) {
String actionString = jsonHelper.extractStringNamed(DelinquencyActionParameters.ACTION, json);
validateActionString(actionString);
if ("pause".equalsIgnoreCase(actionString)) {
return DelinquencyAction.PAUSE;
} else if ("resume".equalsIgnoreCase(actionString)) {
return DelinquencyAction.RESUME;
} else {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.generalError("loan-delinquency-action-invalid-action",
"Invalid Delinquency Action: " + actionString)));
}
}
private void validateActionString(String actionString) {
if (StringUtils.isEmpty(actionString)) {
raiseValidationError("loan-delinquency-action-missing-action", "Delinquency Action must not be null or empty");
}
}
private LocalDate extractStartDate(JsonElement json) {
String dateFormat = jsonHelper.extractStringNamed(DelinquencyActionParameters.DATE_FORMAT, json);
String locale = jsonHelper.extractStringNamed(DelinquencyActionParameters.LOCALE, json);
return jsonHelper.extractLocalDateNamed(DelinquencyActionParameters.START_DATE, json, dateFormat,
JsonParserHelper.localeFromString(locale));
}
private LocalDate extractEndDate(JsonElement json) {
String dateFormat = jsonHelper.extractStringNamed(DelinquencyActionParameters.DATE_FORMAT, json);
String locale = jsonHelper.extractStringNamed(DelinquencyActionParameters.LOCALE, json);
return jsonHelper.extractLocalDateNamed(DelinquencyActionParameters.END_DATE, json, dateFormat,
JsonParserHelper.localeFromString(locale));
}
private void raiseValidationError(String globalisationMessageCode, String msg) throws PlatformApiDataValidationException {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.generalError(globalisationMessageCode, msg)));
}
}
@@ -0,0 +1,51 @@
/**
* 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.delinquency.validator;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.Data;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
@Data
public class LoanDelinquencyActionData {
public Long id;
public DelinquencyAction action;
public LocalDate startDate;
public LocalDate endDate;
public Long createdById;
public OffsetDateTime createdOn;
public Long updatedById;
public OffsetDateTime lastModifiedOn;
public LoanDelinquencyActionData(LoanDelinquencyAction loanDelinquencyAction) {
this.id = loanDelinquencyAction.getId();
this.action = loanDelinquencyAction.getAction();
this.startDate = loanDelinquencyAction.getStartDate();
this.endDate = loanDelinquencyAction.getEndDate();
loanDelinquencyAction.getCreatedBy().ifPresent(this::setCreatedById);
loanDelinquencyAction.getLastModifiedBy().ifPresent(this::setUpdatedById);
this.createdOn = loanDelinquencyAction.getCreatedDateTime();
this.lastModifiedOn = loanDelinquencyAction.getLastModifiedDateTime();
}
}
@@ -109,7 +109,9 @@ import org.apache.fineract.portfolio.collateralmanagement.service.LoanCollateral
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.delinquency.api.DelinquencyApiResourceSwagger;
import org.apache.fineract.portfolio.delinquency.data.LoanDelinquencyTagHistoryData;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformService;
import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData;
import org.apache.fineract.portfolio.floatingrates.data.InterestRatePeriodData;
import org.apache.fineract.portfolio.fund.data.FundData;
import org.apache.fineract.portfolio.fund.service.FundReadPlatformService;
@@ -257,6 +259,7 @@ public class LoansApiResource {
private final DefaultToApiJsonSerializer<LoanAccountData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<LoanApprovalData> loanApprovalDataToApiJsonSerializer;
private final DefaultToApiJsonSerializer<LoanScheduleData> loanScheduleToApiJsonSerializer;
private final DefaultToApiJsonSerializer<LoanDelinquencyActionData> delinquencyActionSerializer;
private final ApiRequestParameterHelper apiRequestParameterHelper;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -778,6 +781,58 @@ public class LoansApiResource {
return getDelinquencyTagHistory(null, loanExternalId, uriInfo);
}
@GET
@Path("{loanId}/delinquency-actions")
@Consumes({ MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve delinquency actions related to the loan", description = "")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = DelinquencyApiResourceSwagger.GetDelinquencyActionsResponse.class)))) })
public String getLoanDelinquencyActions(@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
@Context final UriInfo uriInfo) {
return getLoanDelinquencyActions(loanId, null, uriInfo);
}
@GET
@Path("external-id/{loanExternalId}/delinquency-actions")
@Consumes({ MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve delinquency actions related to the loan", description = "")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = DelinquencyApiResourceSwagger.GetDelinquencyActionsResponse.class)))) })
public String getLoanDelinquencyActions(
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
@Context final UriInfo uriInfo) {
return getLoanDelinquencyActions(null, loanExternalId, uriInfo);
}
@POST
@Path("{loanId}/delinquency-actions")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Adds a new delinquency action for a loan", description = "")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = DelinquencyApiResourceSwagger.PostLoansDelinquencyActionRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DelinquencyApiResourceSwagger.PostLoansDelinquencyActionResponse.class))) })
public String createLoanDelinquencyAction(@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
@Context final UriInfo uriInfo, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return createLoanDelinquencyAction(loanId, ExternalId.empty(), apiRequestBodyAsJson);
}
@POST
@Path("external-id/{loanExternalId}/delinquency-actions")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Adds a new delinquency action for a loan", description = "")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = DelinquencyApiResourceSwagger.PostLoansDelinquencyActionRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DelinquencyApiResourceSwagger.PostLoansDelinquencyActionResponse.class))) })
public String createLoanDelinquencyAction(
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
@Context final UriInfo uriInfo, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return createLoanDelinquencyAction(null, ExternalIdFactory.produce(loanExternalId), apiRequestBodyAsJson);
}
private String retrieveApprovalTemplate(final Long loanId, final String loanExternalIdStr, final String templateType,
final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
@@ -1154,4 +1209,26 @@ public class LoansApiResource {
}
return resolvedLoanId;
}
private String getLoanDelinquencyActions(final Long loanId, final String loanExternalIdStr, final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
ExternalId loanExternalId = ExternalIdFactory.produce(loanExternalIdStr);
Long resolvedLoanId = getResolvedLoanId(loanId, loanExternalId);
final Collection<LoanDelinquencyAction> delinquencyActions = this.delinquencyReadPlatformService
.retrieveLoanDelinquencyActions(resolvedLoanId);
List<LoanDelinquencyActionData> result = delinquencyActions.stream().map(LoanDelinquencyActionData::new).toList();
return this.jsonSerializerTagHistory.serialize(result);
}
private String createLoanDelinquencyAction(Long loanId, ExternalId loanExternalId, String apiRequestBodyAsJson) {
context.authenticatedUser().validateHasUpdatePermission(RESOURCE_NAME_FOR_PERMISSIONS);
Long resolvedLoanId = getResolvedLoanId(loanId, loanExternalId);
CommandWrapperBuilder builder = new CommandWrapperBuilder().createDelinquencyAction(resolvedLoanId);
builder.withJson(apiRequestBodyAsJson);
CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(builder.build());
return delinquencyActionSerializer.serialize(result);
}
}
@@ -964,9 +964,9 @@ final class LoansApiResourceSwagger {
private String externalId;
}
static final class GetLoansLoanIdCollectionData {
static final class GetLoansLoanIdDelinquencySummary {
private GetLoansLoanIdCollectionData() {}
private GetLoansLoanIdDelinquencySummary() {}
@Schema(example = "100.000000")
public Double availableDisbursementAmount;
@@ -989,6 +989,16 @@ final class LoansApiResourceSwagger {
public LocalDate lastRepaymentDate;
@Schema(example = "100.000000")
public Double lastRepaymentAmount;
@Schema(example = "true")
public Boolean delinquencyCalculationPaused;
@Schema(example = "[2022, 07, 05]")
public LocalDate delinquencyPausePeriodStartDate;
@Schema(example = "[2022, 07, 10]")
public LocalDate delinquencyPausePeriodEndDate;
}
@Schema(example = "1")
@@ -1062,7 +1072,7 @@ final class LoansApiResourceSwagger {
@Schema(description = "Set of GetLoansLoanIdDisbursementDetails")
public Set<GetLoansLoanIdDisbursementDetails> disbursementDetails;
@Schema(description = "Delinquent data")
public GetLoansLoanIdCollectionData delinquent;
public GetLoansLoanIdDelinquencySummary delinquent;
@Schema(description = "Set of charges")
public List<GetLoansLoanIdLoanChargeData> charges;
public GetDelinquencyRangesResponse delinquencyRange;
@@ -43,9 +43,13 @@ public final class CollectionData {
private LocalDate lastRepaymentDate;
private BigDecimal lastRepaymentAmount;
public boolean delinquencyCalculationPaused;
public LocalDate delinquencyPausePeriodStartDate;
public LocalDate delinquencyPausePeriodEndDate;
public static CollectionData template() {
final BigDecimal zero = BigDecimal.ZERO;
return new CollectionData(zero, 0L, null, 0L, null, zero, null, zero, null, zero);
return new CollectionData(zero, 0L, null, 0L, null, zero, null, zero, null, zero, false, null, null);
}
}
@@ -0,0 +1,204 @@
/**
* 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.delinquency.service;
import static java.time.Month.JANUARY;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository;
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper;
import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper;
import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper;
import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.junit.jupiter.api.Assertions;
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;
@ExtendWith(MockitoExtension.class)
class DelinquencyReadPlatformServiceImplTest {
@Mock
private DelinquencyRangeRepository repositoryRange;
@Mock
private DelinquencyBucketRepository repositoryBucket;
@Mock
private LoanDelinquencyTagHistoryRepository repositoryLoanDelinquencyTagHistory;
@Mock
private DelinquencyRangeMapper mapperRange;
@Mock
private DelinquencyBucketMapper mapperBucket;
@Mock
private LoanDelinquencyTagMapper mapperLoanDelinquencyTagHistory;
@Mock
private LoanRepository loanRepository;
@Mock
private LoanDelinquencyDomainService loanDelinquencyDomainService;
@Mock
private LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag;
@Mock
private LoanDelinquencyActionRepository loanDelinquencyActionRepository;
@InjectMocks
private DelinquencyReadPlatformServiceImpl underTest;
@Test
public void testNoEnrichmentWhenThereIsNoDelinquencyAction() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of();
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 12));
// then
Assertions.assertFalse(collectionData.isDelinquencyCalculationPaused());
Assertions.assertNull(collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertNull(collectionData.getDelinquencyPausePeriodEndDate());
}
@Test
public void testMultiplePausesWithoutResumeActionCurrentlyInPauseFirstDay() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of(
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 10), LocalDate.of(2023, JANUARY, 11)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 12), LocalDate.of(2023, JANUARY, 13)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 15), LocalDate.of(2023, JANUARY, 20)));
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 12));
// then
Assertions.assertTrue(collectionData.isDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 12), collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 13), collectionData.getDelinquencyPausePeriodEndDate());
}
@Test
public void testMultiplePausesWithoutResumeActionCurrentlyInPauseLastDay() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of(
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 10), LocalDate.of(2023, JANUARY, 11)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 12), LocalDate.of(2023, JANUARY, 13)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 15), LocalDate.of(2023, JANUARY, 20)));
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 13));
// then
Assertions.assertTrue(collectionData.isDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 12), collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 13), collectionData.getDelinquencyPausePeriodEndDate());
}
@Test
public void testMultiplePausesWithoutResumeActionCurrentBusinessDateBetweenStartAndEndDate() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of(
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 10), LocalDate.of(2023, JANUARY, 11)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 12), LocalDate.of(2023, JANUARY, 14)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 15), LocalDate.of(2023, JANUARY, 20)));
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 13));
// then
Assertions.assertTrue(collectionData.isDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 12), collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 14), collectionData.getDelinquencyPausePeriodEndDate());
}
@Test
public void testMultiplePausesWithoutResumeCurrentBusinessDateIsNotOverlappingWithAnyOfThePauses() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of(
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 10), LocalDate.of(2023, JANUARY, 11)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 13), LocalDate.of(2023, JANUARY, 14)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 15), LocalDate.of(2023, JANUARY, 20)));
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 12));
// then
Assertions.assertFalse(collectionData.isDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 10), collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 11), collectionData.getDelinquencyPausePeriodEndDate());
}
@Test
public void testResumeIsAppliedToOneOfThePauseNotActive() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of(
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 10), LocalDate.of(2023, JANUARY, 20)),
new LoanDelinquencyAction(null, RESUME, LocalDate.of(2023, JANUARY, 11), null),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 13), LocalDate.of(2023, JANUARY, 14)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 15), LocalDate.of(2023, JANUARY, 20)));
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 12));
// then
Assertions.assertFalse(collectionData.isDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 10), collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 11), collectionData.getDelinquencyPausePeriodEndDate());
}
@Test
public void testResumeIsAppliedToOneOfThePauseActive() {
// given
CollectionData collectionData = CollectionData.template();
Collection<LoanDelinquencyAction> delinquencyActions = List.of(
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 10), LocalDate.of(2023, JANUARY, 20)),
new LoanDelinquencyAction(null, RESUME, LocalDate.of(2023, JANUARY, 11), null),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 13), LocalDate.of(2023, JANUARY, 14)),
new LoanDelinquencyAction(null, PAUSE, LocalDate.of(2023, JANUARY, 15), LocalDate.of(2023, JANUARY, 20)));
// when
underTest.enrichWithDelinquencyPausePeriodInfo(collectionData, delinquencyActions, LocalDate.of(2023, JANUARY, 11));
// then
Assertions.assertTrue(collectionData.isDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 10), collectionData.getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.of(2023, JANUARY, 11), collectionData.getDelinquencyPausePeriodEndDate());
}
}
@@ -0,0 +1,293 @@
/**
* 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.delinquency.validator;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.ACTION;
import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.DATE_FORMAT;
import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.END_DATE;
import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.LOCALE;
import static org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParameters.START_DATE;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.JsonParser;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction;
import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import org.mockito.Mockito;
class DelinquencyActionParseAndValidatorTest {
private final FromJsonHelper fromJsonHelper = new FromJsonHelper();
private final DelinquencyActionParseAndValidator underTest = new DelinquencyActionParseAndValidator(fromJsonHelper);
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US);
@Test
public void testParseAndValidationIsOKForPause() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("pause", "09 September 2022", "19 September 2022");
LoanDelinquencyAction parsedDelinquencyAction = underTest.validateAndParseUpdate(command, loan, List.of(),
localDate("09 September 2022"));
Assertions.assertEquals(PAUSE, parsedDelinquencyAction.getAction());
Assertions.assertEquals(localDate("09 September 2022"), parsedDelinquencyAction.getStartDate());
Assertions.assertEquals(localDate("19 September 2022"), parsedDelinquencyAction.getEndDate());
}
@Test
public void testParseAndValidationIsOKForResume() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("resume", "09 September 2022", null);
LoanDelinquencyAction parsedDelinquencyAction = underTest.validateAndParseUpdate(command, loan,
List.of(loanDelinquencyAction(PAUSE, "05 September 2022", "15 September 2022")), localDate("09 September 2022"));
Assertions.assertEquals(DelinquencyAction.RESUME, parsedDelinquencyAction.getAction());
Assertions.assertEquals(localDate("09 September 2022"), parsedDelinquencyAction.getStartDate());
Assertions.assertNull(parsedDelinquencyAction.getEndDate());
}
@Test
public void testPauseBothStartAndEndDateIsOverlappingWithAnActivePause() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "14 September 2022", "22 September 2022"));
JsonCommand command = delinquencyAction("pause", "09 September 2022", "15 September 2022");
assertPlatformValidationException("Delinquency pause period cannot overlap with another pause period",
"loan-delinquency-action-overlapping",
() -> underTest.validateAndParseUpdate(command, loan, existing, localDate("09 September 2022")));
}
@Test
public void testPauseStartIsOverlappingWithAnActivePause() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "14 September 2022", "22 September 2022"));
JsonCommand command = delinquencyAction("pause", "15 September 2022", "23 September 2022");
assertPlatformValidationException("Delinquency pause period cannot overlap with another pause period",
"loan-delinquency-action-overlapping",
() -> underTest.validateAndParseUpdate(command, loan, existing, localDate("09 September 2022")));
}
@Test
public void testNewPauseEndIsTheSameAsExistingPauseStart() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "15 September 2022", "22 September 2022"));
JsonCommand command = delinquencyAction("pause", "09 September 2022", "15 September 2022");
assertPlatformValidationException("Delinquency pause period cannot overlap with another pause period",
"loan-delinquency-action-overlapping",
() -> underTest.validateAndParseUpdate(command, loan, existing, localDate("09 September 2022")));
}
@Test
public void testNewPauseEndIsTheSameAsExistingPauseEnd() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "15 September 2022", "22 September 2022"));
JsonCommand command = delinquencyAction("pause", "22 September 2022", "25 September 2022");
assertPlatformValidationException("Delinquency pause period cannot overlap with another pause period",
"loan-delinquency-action-overlapping",
() -> underTest.validateAndParseUpdate(command, loan, existing, localDate("09 September 2022")));
}
@Test
public void testNewPauseEndIsOverlappingWithExistingPause() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "15 September 2022", "22 September 2022"));
JsonCommand command = delinquencyAction("pause", "13 September 2022", "20 September 2022");
assertPlatformValidationException("Delinquency pause period cannot overlap with another pause period",
"loan-delinquency-action-overlapping",
() -> underTest.validateAndParseUpdate(command, loan, existing, localDate("09 September 2022")));
}
@Test
public void testNewPauseIsNotOverlappingBecauseThereWasAResume() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("pause", "18 September 2022", "20 September 2022");
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "15 September 2022", "22 September 2022"), //
loanDelinquencyAction(RESUME, "17 September 2022") //
);
LoanDelinquencyAction parsedDelinquencyAction = underTest.validateAndParseUpdate(command, loan, existing,
localDate("18 September 2022"));
Assertions.assertEquals(PAUSE, parsedDelinquencyAction.getAction());
Assertions.assertEquals(localDate("18 September 2022"), parsedDelinquencyAction.getStartDate());
Assertions.assertEquals(localDate("20 September 2022"), parsedDelinquencyAction.getEndDate());
}
@Test
public void testResumeIsNotOverlappingWithAnActivePause() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
List<LoanDelinquencyAction> existing = List.of(loanDelinquencyAction(PAUSE, "05 September 2022", "08 September 2022"));
JsonCommand command = delinquencyAction("resume", "09 September 2022", null);
assertPlatformValidationException("Resume Delinquency Action can only be created during an active pause",
"loan-delinquency-action-resume-should-be-on-pause",
() -> underTest.validateAndParseUpdate(command, loan, existing, localDate("09 September 2022")));
}
@Test
public void testValidationErrorWhenDelinquencyActionIsMissing() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.APPROVED);
JsonCommand command = delinquencyAction(null, "09 September 2022", "19 September 2022");
assertPlatformValidationException("Delinquency Action must not be null or empty", "loan-delinquency-action-missing-action",
() -> underTest.validateAndParseUpdate(command, loan, List.of(), localDate("09 September 2022")));
}
@Test
public void testValidationErrorWhenLoanIsNotActive() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.APPROVED);
JsonCommand command = delinquencyAction("pause", "09 September 2022", "19 September 2022");
assertPlatformValidationException("Delinquency actions can be created only for active loans.",
"loan-delinquency-action-invalid-loan-state",
() -> underTest.validateAndParseUpdate(command, loan, List.of(), localDate("09 September 2022")));
}
@Test
public void testValidationErrorResumeShouldHaveNoEndDate() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("resume", "09 September 2022", "19 September 2022");
assertPlatformValidationException("Resume Delinquency action can not have end date",
"loan-delinquency-action-resume-should-have-no-end-date",
() -> underTest.validateAndParseUpdate(command, loan, List.of(), localDate("09 September 2022")));
}
@Test
public void testValidationErrorResumeInvalidStartDate() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("resume", "09 September 2022", "19 September 2022");
assertPlatformValidationException("Start date of the Resume Delinquency action must be the current business date",
"loan-delinquency-action-invalid-start-date",
() -> underTest.validateAndParseUpdate(command, loan, List.of(), localDate("10 September 2022")));
}
@Test
public void testValidationErrorPausePeriodShouldBeAtLeastOneDay() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("pause", "10 September 2022", "10 September 2022");
assertPlatformValidationException("Delinquency pause period must be at least one day",
"loan-delinquency-action-invalid-start-date-and-end-date",
() -> underTest.validateAndParseUpdate(command, loan, List.of(), localDate("09 September 2022")));
}
@Test
public void testValidationErrorPausePeriodMustBeInFuture() throws JsonProcessingException {
Loan loan = Mockito.mock(Loan.class);
Mockito.when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE);
JsonCommand command = delinquencyAction("pause", "08 September 2022", "09 September 2022");
assertPlatformValidationException("Start date of pause period must be in the future", "loan-delinquency-action-invalid-start-date",
() -> underTest.validateAndParseUpdate(command, loan, List.of(), localDate("09 September 2022")));
}
@NotNull
private JsonCommand delinquencyAction(String action, String startDate, String endDate) throws JsonProcessingException {
Map<String, Object> map = new HashMap<>();
map.put(ACTION, action);
map.put(DATE_FORMAT, "dd MMMM yyyy");
map.put(LOCALE, "en");
map.put(START_DATE, startDate);
map.put(END_DATE, endDate);
return createJsonCommand(map);
}
private LocalDate localDate(String date) {
return LocalDate.parse(date, DATE_TIME_FORMATTER);
}
@NotNull
private JsonCommand createJsonCommand(Map<String, Object> jsonMap) throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonMap);
return new JsonCommand(null, JsonParser.parseString(json));
}
private void assertPlatformValidationException(String message, String code, Executable executable) {
PlatformApiDataValidationException validationException = assertThrows(PlatformApiDataValidationException.class, executable);
assertPlatformException(message, code, validationException);
}
private void assertPlatformException(String expectedMessage, String expectedCode,
PlatformApiDataValidationException platformApiDataValidationException) {
Assertions.assertEquals(expectedMessage, platformApiDataValidationException.getErrors().get(0).getDefaultUserMessage());
Assertions.assertEquals(expectedCode, platformApiDataValidationException.getErrors().get(0).getUserMessageGlobalisationCode());
}
private LoanDelinquencyAction loanDelinquencyAction(DelinquencyAction action, String startTime, String endTime) {
return new LoanDelinquencyAction(null, action, localDate(startTime), localDate(endTime));
}
private LoanDelinquencyAction loanDelinquencyAction(DelinquencyAction action, String startTime) {
return new LoanDelinquencyAction(null, action, localDate(startTime), null);
}
}
@@ -127,7 +127,7 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L,
loanForProcessing);
CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, BigDecimal.ZERO, null, null,
null, null);
null, null, false, null, null);
Map<Long, CollectionData> installmentsCollection = new HashMap<>();
@@ -239,10 +239,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L,
loanForProcessing);
CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, BigDecimal.ZERO, null, null,
null, null);
null, null, false, null, null);
CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate,
installmentPrincipalAmount, null, null, null, null);
installmentPrincipalAmount, null, null, null, null, false, null, null);
Map<Long, CollectionData> installmentsCollection = new HashMap<>();
installmentsCollection.put(1L, installmentCollectionData);
@@ -305,10 +305,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L,
loanForProcessing);
CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate, BigDecimal.ZERO, null, null,
null, null);
null, null, false, null, null);
CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate,
installmentPrincipalAmount, null, null, null, null);
installmentPrincipalAmount, null, null, null, null, false, null, null);
Map<Long, CollectionData> installmentsCollection = new HashMap<>();
installmentsCollection.put(1L, installmentCollectionData);
@@ -79,6 +79,7 @@ public abstract class BaseLoanIntegrationTest {
protected final LoanProductHelper loanProductHelper = new LoanProductHelper();
protected JournalEntryHelper journalEntryHelper = new JournalEntryHelper(requestSpec, responseSpec);
protected BusinessDateHelper businessDateHelper = new BusinessDateHelper();
protected DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN);
// asset
protected final Account loansReceivableAccount = accountHelper.createAssetAccount();
@@ -229,7 +230,6 @@ public abstract class BaseLoanIntegrationTest {
assertNull(loanDetails.getTransactions(), "No transaction is expected");
} else {
Assertions.assertEquals(transactions.length, loanDetails.getTransactions().size());
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN);
Arrays.stream(transactions).forEach(tr -> {
boolean found = loanDetails.getTransactions().stream()
.anyMatch(item -> Objects.equals(item.getAmount(), tr.amount) && Objects.equals(item.getType().getValue(), tr.type)
@@ -329,8 +329,13 @@ public abstract class BaseLoanIntegrationTest {
protected Long applyAndApproveLoan(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount,
int numberOfRepayments) {
PostLoansResponse postLoansResponse = loanTransactionHelper
.applyLoan(applyLoanRequest(clientId, loanProductId, loanDisbursementDate, amount, numberOfRepayments));
return applyAndApproveLoan(clientId, loanProductId, loanDisbursementDate, amount, numberOfRepayments, null);
}
protected Long applyAndApproveLoan(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount,
int numberOfRepayments, String externalId) {
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(
applyLoanRequest(clientId, loanProductId, loanDisbursementDate, amount, numberOfRepayments).externalId(externalId));
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount));
@@ -0,0 +1,259 @@
/**
* 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 java.lang.Boolean.TRUE;
import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE;
import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.BusinessDateRequest;
import org.apache.fineract.client.models.GetDelinquencyActionsResponse;
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansDelinquencyActionResponse;
import org.apache.fineract.client.util.CallFailedRuntimeException;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@Slf4j
@ExtendWith(LoanTestLifecycleExtension.class)
public class DelinquencyActionIntegrationTests extends BaseLoanIntegrationTest {
public static final BigDecimal DOWN_PAYMENT_PERCENTAGE = new BigDecimal(25);
private final ClientHelper clientHelper = new ClientHelper(this.requestSpec, this.responseSpec);
@Test
public void testCreateAndReadPauseDelinquencyAction() {
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
// Create Loan Product
Long loanProductId = createLoanProductWith25PctDownPayment(true, true);
// Apply and Approve Loan
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2);
// Disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");
// Create Delinquency Pause for the Loan
PostLoansDelinquencyActionResponse response = loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE,
"10 January 2023", "15 January 2023");
List<GetDelinquencyActionsResponse> loanDelinquencyActions = loanTransactionHelper.getLoanDelinquencyActions(loanId);
Assertions.assertNotNull(loanDelinquencyActions);
Assertions.assertEquals(1, loanDelinquencyActions.size());
Assertions.assertEquals("PAUSE", loanDelinquencyActions.get(0).getAction());
Assertions.assertEquals(LocalDate.parse("10 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getStartDate());
Assertions.assertEquals(LocalDate.parse("15 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getEndDate());
});
}
@Test
public void testCreateAndReadPauseDelinquencyActionUsingExternalId() {
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
// Create Loan Product
Long loanProductId = createLoanProductWith25PctDownPayment(true, true);
// Create external ID
String externalId = UUID.randomUUID().toString();
// Apply and Approve Loan
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2, externalId);
// Disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");
// Create Delinquency Pause for the Loan
PostLoansDelinquencyActionResponse response = loanTransactionHelper.createLoanDelinquencyAction(externalId, PAUSE,
"10 January 2023", "15 January 2023");
List<GetDelinquencyActionsResponse> loanDelinquencyActions = loanTransactionHelper.getLoanDelinquencyActions(externalId);
Assertions.assertNotNull(loanDelinquencyActions);
Assertions.assertEquals(1, loanDelinquencyActions.size());
Assertions.assertEquals("PAUSE", loanDelinquencyActions.get(0).getAction());
Assertions.assertEquals(LocalDate.parse("10 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getStartDate());
Assertions.assertEquals(LocalDate.parse("15 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getEndDate());
});
}
@Test
public void testCreatePauseAndResumeDelinquencyAction() {
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
// Create Loan Product
Long loanProductId = createLoanProductWith25PctDownPayment(true, true);
// Apply and Approve Loan
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2);
// Disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");
// Create Delinquency Pause for the Loan
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "10 January 2023", "15 January 2023");
// Update business date
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("14 January 2023")
.dateFormat(DATETIME_PATTERN).locale("en"));
// Create 2nd Delinquency Resume for the Loan
loanTransactionHelper.createLoanDelinquencyAction(loanId, RESUME, "14 January 2023");
List<GetDelinquencyActionsResponse> loanDelinquencyActions = loanTransactionHelper.getLoanDelinquencyActions(loanId);
Assertions.assertNotNull(loanDelinquencyActions);
Assertions.assertEquals(2, loanDelinquencyActions.size());
Assertions.assertEquals("PAUSE", loanDelinquencyActions.get(0).getAction());
Assertions.assertEquals(LocalDate.parse("10 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getStartDate());
Assertions.assertEquals(LocalDate.parse("15 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getEndDate());
Assertions.assertEquals("RESUME", loanDelinquencyActions.get(1).getAction());
Assertions.assertEquals(LocalDate.parse("14 January 2023", dateTimeFormatter), loanDelinquencyActions.get(1).getStartDate());
});
}
@Test
public void testCreatePauseAndResumeDelinquencyActionWithStatusFlag() {
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
// Create Loan Product
Long loanProductId = createLoanProductWith25PctDownPayment(true, true);
// Apply and Approve Loan
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2);
// Disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");
// Create Delinquency Pause for the Loan
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "10 January 2023", "15 January 2023");
// Update business date
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("14 January 2023")
.dateFormat(DATETIME_PATTERN).locale("en"));
// Validate Loan Delinquency Pause Period on Loan
validateLoanDelinquencyPausePerios(loanId, "10 January 2023", "15 January 2023", true);
// Create a Resume for the Loan for the current business date, it is still expected to be in pause
loanTransactionHelper.createLoanDelinquencyAction(loanId, RESUME, "14 January 2023");
// Validate Loan Delinquency Pause Period on Loan
validateLoanDelinquencyPausePerios(loanId, "10 January 2023", "14 January 2023", true);
// Update business date to 15 January 2023
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("15 January 2023")
.dateFormat(DATETIME_PATTERN).locale("en"));
// Validate Loan Delinquency Pause Period on Loan
validateLoanDelinquencyPausePerios(loanId, "10 January 2023", "14 January 2023", false);
// Create a new pause action for the future
loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "20 January 2023", "25 January 2023");
// Validate Loan Delinquency Pause Period on Loan
validateLoanDelinquencyPausePerios(loanId, "10 January 2023", "14 January 2023", false);
});
}
@Test
public void testValidationErrorIsThrownWhenCreatingActionInThePast() {
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
// Create Loan Product
Long loanProductId = createLoanProductWith25PctDownPayment(true, true);
// Apply and Approve Loan
Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2023", 1500.0, 2);
// Disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");
// Create Delinquency Pause for the Loan in the past
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "05 December 2022", "15 January 2023"));
assertTrue(exception.getMessage().contains("Start date of pause period must be in the future"));
});
}
private void validateLoanDelinquencyPausePerios(Long loanId, String expectedStart, String expectedEnd, Boolean expectedPauseState) {
GetLoansLoanIdResponse loan = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue());
Assertions.assertNotNull(loan.getDelinquent());
Assertions.assertEquals(expectedPauseState, loan.getDelinquent().getDelinquencyCalculationPaused());
Assertions.assertEquals(LocalDate.parse(expectedStart, dateTimeFormatter),
loan.getDelinquent().getDelinquencyPausePeriodStartDate());
Assertions.assertEquals(LocalDate.parse(expectedEnd, dateTimeFormatter), loan.getDelinquent().getDelinquencyPausePeriodEndDate());
}
private Long createLoanProductWith25PctDownPayment(boolean autoDownPaymentEnabled, boolean multiDisburseEnabled) {
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct();
product.setMultiDisburseLoan(multiDisburseEnabled);
if (!multiDisburseEnabled) {
product.disallowExpectedDisbursements(null);
product.setAllowApprovedDisbursedAmountsOverApplied(null);
product.overAppliedCalculationType(null);
product.overAppliedNumber(null);
}
product.setEnableDownPayment(true);
product.setDisbursedAmountPercentageForDownPayment(DOWN_PAYMENT_PERCENTAGE);
product.setEnableAutoRepaymentForDownPayment(autoDownPaymentEnabled);
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
GetLoanProductsProductIdResponse getLoanProductsProductIdResponse = loanProductHelper
.retrieveLoanProductById(loanProductResponse.getResourceId());
Long loanProductId = loanProductResponse.getResourceId();
assertEquals(TRUE, getLoanProductsProductIdResponse.getEnableDownPayment());
assertNotNull(getLoanProductsProductIdResponse.getDisbursedAmountPercentageForDownPayment());
assertEquals(0, getLoanProductsProductIdResponse.getDisbursedAmountPercentageForDownPayment().compareTo(DOWN_PAYMENT_PERCENTAGE));
assertEquals(autoDownPaymentEnabled, getLoanProductsProductIdResponse.getEnableAutoRepaymentForDownPayment());
assertEquals(multiDisburseEnabled, getLoanProductsProductIdResponse.getMultiDisburseLoan());
return loanProductId;
}
}
@@ -41,7 +41,7 @@ import org.apache.fineract.client.models.GetDelinquencyBucketsResponse;
import org.apache.fineract.client.models.GetDelinquencyRangesResponse;
import org.apache.fineract.client.models.GetDelinquencyTagHistoryResponse;
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdCollectionData;
import org.apache.fineract.client.models.GetLoansLoanIdDelinquencySummary;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentSchedule;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
@@ -903,7 +903,7 @@ public class DelinquencyBucketsIntegrationTest {
getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
loanTransactionHelper.printDelinquencyData(getLoansLoanIdResponse);
GetLoansLoanIdCollectionData getLoansLoanIdCollectionData = getLoansLoanIdResponse.getDelinquent();
GetLoansLoanIdDelinquencySummary getLoansLoanIdCollectionData = getLoansLoanIdResponse.getDelinquent();
assertNotNull(getLoansLoanIdCollectionData);
assertEquals(0, getLoansLoanIdCollectionData.getDelinquentDays());
assertEquals(0, getLoansLoanIdCollectionData.getPastDueDays());
@@ -992,7 +992,7 @@ public class DelinquencyBucketsIntegrationTest {
getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId);
loanTransactionHelper.printDelinquencyData(getLoansLoanIdResponse);
GetLoansLoanIdCollectionData getLoansLoanIdCollectionData = getLoansLoanIdResponse.getDelinquent();
GetLoansLoanIdDelinquencySummary getLoansLoanIdCollectionData = getLoansLoanIdResponse.getDelinquent();
assertNotNull(getLoansLoanIdCollectionData);
assertEquals(0, getLoansLoanIdCollectionData.getDelinquentDays());
assertEquals(0, getLoansLoanIdCollectionData.getPastDueDays());
@@ -45,13 +45,14 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.AdvancedPaymentData;
import org.apache.fineract.client.models.DeleteLoansLoanIdChargesChargeIdResponse;
import org.apache.fineract.client.models.DeleteLoansLoanIdResponse;
import org.apache.fineract.client.models.GetDelinquencyActionsResponse;
import org.apache.fineract.client.models.GetDelinquencyTagHistoryResponse;
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
import org.apache.fineract.client.models.GetLoanProductsResponse;
import org.apache.fineract.client.models.GetLoansApprovalTemplateResponse;
import org.apache.fineract.client.models.GetLoansLoanIdChargesChargeIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdChargesTemplateResponse;
import org.apache.fineract.client.models.GetLoansLoanIdCollectionData;
import org.apache.fineract.client.models.GetLoansLoanIdDelinquencySummary;
import org.apache.fineract.client.models.GetLoansLoanIdDisbursementDetails;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentSchedule;
@@ -63,6 +64,8 @@ import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTransactionId
import org.apache.fineract.client.models.GetPaymentTypesResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansDelinquencyActionRequest;
import org.apache.fineract.client.models.PostLoansDelinquencyActionResponse;
import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdChargesRequest;
@@ -88,6 +91,7 @@ import org.apache.fineract.integrationtests.client.IntegrationTest;
import org.apache.fineract.integrationtests.common.CommonConstants;
import org.apache.fineract.integrationtests.common.PaymentTypeHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Workbook;
@@ -279,6 +283,32 @@ public class LoanTransactionHelper extends IntegrationTest {
return GSON.fromJson(response, delinquencyTagsListType);
}
public List<GetDelinquencyActionsResponse> getLoanDelinquencyActions(final Long loanID) {
return ok(fineract().loans.getLoanDelinquencyActions(loanID));
}
public List<GetDelinquencyActionsResponse> getLoanDelinquencyActions(String externalId) {
return ok(fineract().loans.getLoanDelinquencyActions1(externalId));
}
public PostLoansDelinquencyActionResponse createLoanDelinquencyAction(final Long loanid, DelinquencyAction action, String startDate,
String endDate) {
PostLoansDelinquencyActionRequest postLoansDelinquencyAction = new PostLoansDelinquencyActionRequest().action(action.name())
.startDate(startDate).endDate(endDate).locale("en").dateFormat("dd MMMM yyyy");
return ok(fineract().loans.createLoanDelinquencyAction(loanid, postLoansDelinquencyAction));
}
public PostLoansDelinquencyActionResponse createLoanDelinquencyAction(String externalId, DelinquencyAction action, String startDate,
String endDate) {
PostLoansDelinquencyActionRequest postLoansDelinquencyAction = new PostLoansDelinquencyActionRequest().action(action.name())
.startDate(startDate).endDate(endDate).locale("en").dateFormat("dd MMMM yyyy");
return ok(fineract().loans.createLoanDelinquencyAction1(externalId, postLoansDelinquencyAction));
}
public PostLoansDelinquencyActionResponse createLoanDelinquencyAction(final Long loanid, DelinquencyAction action, String startDate) {
return createLoanDelinquencyAction(loanid, action, startDate, null);
}
public Object getLoanProductDetail(final RequestSpecification requestSpec, final ResponseSpecification responseSpec,
final Integer loanProductId, final String jsonAttributeToGetBack) {
final String URL = "/fineract-provider/api/v1/loanproducts/" + loanProductId + "?associations=all&" + Utils.TENANT_IDENTIFIER;
@@ -1638,7 +1668,7 @@ public class LoanTransactionHelper extends IntegrationTest {
}
public void printDelinquencyData(GetLoansLoanIdResponse getLoansLoanIdResponse) {
GetLoansLoanIdCollectionData getLoansLoanIdCollectionData = getLoansLoanIdResponse.getDelinquent();
GetLoansLoanIdDelinquencySummary getLoansLoanIdCollectionData = getLoansLoanIdResponse.getDelinquent();
if (getLoansLoanIdCollectionData != null) {
log.info("Loan Delinquency {}", getLoansLoanIdCollectionData.toString());
}
@@ -33,7 +33,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.DeleteDelinquencyBucketResponse;
import org.apache.fineract.client.models.GetDelinquencyBucketsResponse;
import org.apache.fineract.client.models.GetDelinquencyRangesResponse;
import org.apache.fineract.client.models.GetLoansLoanIdCollectionData;
import org.apache.fineract.client.models.GetLoansLoanIdDelinquencySummary;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostDelinquencyBucketResponse;
import org.apache.fineract.client.models.PostDelinquencyRangeResponse;
@@ -121,7 +121,7 @@ public class DelinquencyBucketsHelper {
public static void evaluateLoanCollectionData(GetLoansLoanIdResponse getLoansLoanIdResponse, Integer pastDueDays,
Double amountExpected) {
GetLoansLoanIdCollectionData getCollectionData = getLoansLoanIdResponse.getDelinquent();
GetLoansLoanIdDelinquencySummary getCollectionData = getLoansLoanIdResponse.getDelinquent();
if (getCollectionData != null) {
log.info("Loan Delinquency Data in Days {} and Amount {}", getCollectionData.getPastDueDays(),
getCollectionData.getDelinquentAmount());