FINERACT-1971: Fix for not properly resolving delinquency range data for loan in case there's an installment delinquency

This commit is contained in:
Arnold Galovics
2024-01-18 08:28:43 +01:00
committed by Arnold Gálovics
parent ef465562b5
commit cbe8f029e2
4 changed files with 380 additions and 242 deletions
@@ -0,0 +1,268 @@
/**
* 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 java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanDelinquencyRangeChangeBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRange;
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository;
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.loanaccount.data.CollectionData;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
@Slf4j
public class DelinquencyWritePlatformServiceHelper {
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanDelinquencyTagHistoryRepository loanDelinquencyTagRepository;
private final DelinquencyRangeRepository repositoryRange;
private final LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository;
public Map<String, Object> applyDelinquencyForLoan(final Loan loan, final DelinquencyBucket delinquencyBucket, long overdueDays) {
Map<String, Object> changes = new HashMap<>();
if (overdueDays <= 0) { // No Delinquency
log.debug("Loan {} without delinquency range with {} days", loan.getId(), overdueDays);
changes = setLoanDelinquencyTag(loan, null);
} else {
// Sort the ranges based on the minAgeDays
final List<DelinquencyRange> ranges = sortDelinquencyRangesByMinAge(delinquencyBucket.getRanges());
for (final DelinquencyRange delinquencyRange : ranges) {
if (delinquencyRange.getMaximumAgeDays() == null) { // Last Range in the Bucket
if (delinquencyRange.getMinimumAgeDays() <= overdueDays) {
log.debug("Loan {} with delinquency range {} with {} days", loan.getId(), delinquencyRange.getClassification(),
overdueDays);
changes = setLoanDelinquencyTag(loan, delinquencyRange.getId());
break;
}
} else {
if (delinquencyRange.getMinimumAgeDays() <= overdueDays && delinquencyRange.getMaximumAgeDays() >= overdueDays) {
log.debug("Loan {} with delinquency range {} with {} days", loan.getId(), delinquencyRange.getClassification(),
overdueDays);
changes = setLoanDelinquencyTag(loan, delinquencyRange.getId());
break;
}
}
}
}
changes.put("overdueDays", overdueDays);
return changes;
}
public Map<String, Object> setLoanDelinquencyTag(Loan loan, Long delinquencyRangeId) {
Map<String, Object> changes = new HashMap<>();
List<LoanDelinquencyTagHistory> loanDelinquencyTagHistory = new ArrayList<>();
final LocalDate transactionDate = DateUtils.getBusinessLocalDate();
Optional<LoanDelinquencyTagHistory> optLoanDelinquencyTag = this.loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(loan, null);
// The delinquencyRangeId in null means just goes out from Delinquency
LoanDelinquencyTagHistory loanDelinquencyTagPrev = null;
if (delinquencyRangeId == null) {
// The Loan will go out from Delinquency
if (optLoanDelinquencyTag.isPresent()) {
loanDelinquencyTagPrev = optLoanDelinquencyTag.get();
loanDelinquencyTagPrev.setLiftedOnDate(transactionDate);
loanDelinquencyTagHistory.add(loanDelinquencyTagPrev);
changes.put("previous", loanDelinquencyTagPrev.getDelinquencyRange());
// event when loan goes out of delinquency we do not calculate at
// installment level and remove all installment tags, so event needs to raised here.
if (loan.isEnableInstallmentLevelDelinquency()) {
businessEventNotifierService.notifyPostBusinessEvent(new LoanDelinquencyRangeChangeBusinessEvent(loan));
}
}
} else {
if (optLoanDelinquencyTag.isPresent()) {
loanDelinquencyTagPrev = optLoanDelinquencyTag.get();
}
// If the Delinquency Tag has not changed
if (loanDelinquencyTagPrev != null && loanDelinquencyTagPrev.getDelinquencyRange().getId().equals(delinquencyRangeId)) {
changes.put("current", loanDelinquencyTagPrev.getDelinquencyRange());
} else {
// The previous Loan Delinquency Tag will set as Lifted
if (loanDelinquencyTagPrev != null) {
loanDelinquencyTagPrev.setLiftedOnDate(transactionDate);
loanDelinquencyTagHistory.add(loanDelinquencyTagPrev);
changes.put("previous", loanDelinquencyTagPrev.getDelinquencyRange());
}
final DelinquencyRange delinquencyRange = repositoryRange.getReferenceById(delinquencyRangeId);
LoanDelinquencyTagHistory loanDelinquencyTag = new LoanDelinquencyTagHistory(delinquencyRange, loan, transactionDate, null);
loanDelinquencyTagHistory.add(loanDelinquencyTag);
changes.put("current", loanDelinquencyTag.getDelinquencyRange());
}
}
if (loanDelinquencyTagHistory.size() > 0) {
this.loanDelinquencyTagRepository.saveAllAndFlush(loanDelinquencyTagHistory);
// if installment level delinquency is enabled event will be raised at installment level calculation, no
// need to raise the event here
if (!loan.isEnableInstallmentLevelDelinquency()) {
businessEventNotifierService.notifyPostBusinessEvent(new LoanDelinquencyRangeChangeBusinessEvent(loan));
}
}
return changes;
}
public List<DelinquencyRange> sortDelinquencyRangesByMinAge(List<DelinquencyRange> ranges) {
final Comparator<DelinquencyRange> orderByMinAge = new Comparator<DelinquencyRange>() {
@Override
public int compare(DelinquencyRange o1, DelinquencyRange o2) {
return o1.getMinimumAgeDays().compareTo(o2.getMinimumAgeDays());
}
};
Collections.sort(ranges, orderByMinAge);
return ranges;
}
public void applyDelinquencyForLoanInstallments(final Loan loan, final DelinquencyBucket delinquencyBucket,
final Map<Long, CollectionData> installmentsCollectionData) {
boolean isDelinquencyRangeChangedForAnyOfInstallment = false;
for (LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) {
if (installmentsCollectionData.containsKey(installment.getId())) {
boolean isDelinquencySetForInstallment = setInstallmentDelinquencyDetails(loan, installment, delinquencyBucket,
installmentsCollectionData.get(installment.getId()));
isDelinquencyRangeChangedForAnyOfInstallment = isDelinquencyRangeChangedForAnyOfInstallment
|| isDelinquencySetForInstallment;
}
}
// remove tags for non-existing installments that got deleted due to re-schedule
removeDelinquencyTagsForNonExistingInstallments(loan.getId());
// raise event if there is any change at installment level delinquency
if (isDelinquencyRangeChangedForAnyOfInstallment) {
businessEventNotifierService.notifyPostBusinessEvent(new LoanDelinquencyRangeChangeBusinessEvent(loan));
}
}
private void removeDelinquencyTagsForNonExistingInstallments(Long loanId) {
List<LoanInstallmentDelinquencyTag> currentLoanInstallmentDelinquencyTags = loanInstallmentDelinquencyTagRepository
.findByLoanId(loanId);
if (currentLoanInstallmentDelinquencyTags != null && currentLoanInstallmentDelinquencyTags.size() > 0) {
List<Long> loanInstallmentTagsForDelete = currentLoanInstallmentDelinquencyTags.stream()
.filter(tag -> tag.getInstallment() == null).map(tag -> tag.getId()).toList();
if (loanInstallmentTagsForDelete.size() > 0) {
loanInstallmentDelinquencyTagRepository.deleteAllLoanInstallmentsTagsByIds(loanInstallmentTagsForDelete);
}
}
}
private boolean setInstallmentDelinquencyDetails(final Loan loan, final LoanRepaymentScheduleInstallment installment,
final DelinquencyBucket delinquencyBucket, final CollectionData installmentDelinquencyData) {
DelinquencyRange delinquencyRangeForInstallment = getInstallmentDelinquencyRange(delinquencyBucket,
installmentDelinquencyData.getDelinquentDays());
return setDelinquencyDetailsForInstallment(loan, installment, installmentDelinquencyData, delinquencyRangeForInstallment);
}
private DelinquencyRange getInstallmentDelinquencyRange(final DelinquencyBucket delinquencyBucket, Long overDueDays) {
DelinquencyRange delinquencyRangeForInstallment = null;
if (overDueDays > 0) {
// Sort the ranges based on the minAgeDays
final List<DelinquencyRange> ranges = sortDelinquencyRangesByMinAge(delinquencyBucket.getRanges());
for (final DelinquencyRange delinquencyRange : ranges) {
if (delinquencyRange.getMaximumAgeDays() == null) { // Last Range in the Bucket
if (delinquencyRange.getMinimumAgeDays() <= overDueDays) {
delinquencyRangeForInstallment = delinquencyRange;
break;
}
} else {
if (delinquencyRange.getMinimumAgeDays() <= overDueDays && delinquencyRange.getMaximumAgeDays() >= overDueDays) {
delinquencyRangeForInstallment = delinquencyRange;
break;
}
}
}
}
return delinquencyRangeForInstallment;
}
private boolean setDelinquencyDetailsForInstallment(final Loan loan, final LoanRepaymentScheduleInstallment installment,
CollectionData installmentDelinquencyData, final DelinquencyRange delinquencyRangeForInstallment) {
List<LoanInstallmentDelinquencyTag> installmentDelinquencyTags = new ArrayList<>();
LocalDate delinquencyCalculationDate = DateUtils.getBusinessLocalDate();
boolean isDelinquencyRangeChanged = false;
LoanInstallmentDelinquencyTag previousInstallmentDelinquencyTag = loanInstallmentDelinquencyTagRepository
.findByLoanAndInstallment(loan, installment).orElse(null);
if (delinquencyRangeForInstallment == null) {
// if currentInstallmentDelinquencyTag exists and range is null, installment is out of delinquency, delete
// delinquency details
if (previousInstallmentDelinquencyTag != null) {
// event installment out of delinquency
loanInstallmentDelinquencyTagRepository.delete(previousInstallmentDelinquencyTag);
isDelinquencyRangeChanged = true;
}
} else {
LoanInstallmentDelinquencyTag installmentDelinquency = null;
if (previousInstallmentDelinquencyTag != null) {
if (!previousInstallmentDelinquencyTag.getDelinquencyRange().getId().equals(delinquencyRangeForInstallment.getId())) {
// if current delinquency range exists and there is range change, delete previous delinquency
// details and add new range details
installmentDelinquency = new LoanInstallmentDelinquencyTag(delinquencyRangeForInstallment, loan, installment,
delinquencyCalculationDate, null, previousInstallmentDelinquencyTag.getFirstOverdueDate(),
installmentDelinquencyData.getDelinquentAmount());
loanInstallmentDelinquencyTagRepository.delete(previousInstallmentDelinquencyTag);
// event installment delinquency range change
isDelinquencyRangeChanged = true;
} else {
previousInstallmentDelinquencyTag.setOutstandingAmount(installmentDelinquencyData.getDelinquentAmount());
installmentDelinquency = previousInstallmentDelinquencyTag;
}
} else {
// add new range, first time delinquent
installmentDelinquency = new LoanInstallmentDelinquencyTag(delinquencyRangeForInstallment, loan, installment,
delinquencyCalculationDate, null, installmentDelinquencyData.getDelinquentDate(),
installmentDelinquencyData.getDelinquentAmount());
// event installment delinquent
isDelinquencyRangeChanged = true;
}
if (installmentDelinquency != null) {
installmentDelinquencyTags.add(installmentDelinquency);
}
}
if (installmentDelinquencyTags.size() > 0) {
loanInstallmentDelinquencyTagRepository.saveAllAndFlush(installmentDelinquencyTags);
}
return isDelinquencyRangeChanged;
}
}
@@ -20,8 +20,6 @@ package org.apache.fineract.portfolio.delinquency.service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -50,7 +48,6 @@ 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;
@@ -63,7 +60,6 @@ import org.apache.fineract.portfolio.loanaccount.data.CollectionData;
import org.apache.fineract.portfolio.loanaccount.data.LoanDelinquencyData;
import org.apache.fineract.portfolio.loanaccount.data.LoanScheduleDelinquencyData;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository;
import org.springframework.transaction.annotation.Transactional;
@@ -74,20 +70,20 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
private final DelinquencyBucketParseAndValidator dataValidatorBucket;
private final DelinquencyRangeParseAndValidator dataValidatorRange;
private final DelinquencyRangeRepository repositoryRange;
private final DelinquencyBucketRepository repositoryBucket;
private final DelinquencyBucketMappingsRepository repositoryBucketMappings;
private final LoanDelinquencyTagHistoryRepository loanDelinquencyTagRepository;
private final LoanRepositoryWrapper loanRepository;
private final LoanProductRepository loanProductRepository;
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanDelinquencyDomainService loanDelinquencyDomainService;
private final LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository;
private final DelinquencyReadPlatformService delinquencyReadPlatformService;
private final LoanDelinquencyActionRepository loanDelinquencyActionRepository;
private final DelinquencyActionParseAndValidator delinquencyActionParseAndValidator;
private final DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper;
private final BusinessEventNotifierService businessEventNotifierService;
private final DelinquencyWritePlatformServiceHelper delinquencyHelper;
@Override
public CommandProcessingResult createDelinquencyRange(JsonCommand command) {
@@ -186,16 +182,17 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
final CollectionData collectionData = loanDelinquencyData.getLoanCollectionData();
// loan installments delinquent data
final Map<Long, CollectionData> installmentsCollectionData = loanDelinquencyData.getLoanInstallmentsCollectionData();
// delinquency for installments
if (installmentsCollectionData.size() > 0) {
applyDelinquencyDetailsForLoanInstallments(loan, delinquencyBucket, installmentsCollectionData);
}
// delinquency for loan
changes = lookUpDelinquencyRange(loan, delinquencyBucket, collectionData.getDelinquentDays());
log.debug("Delinquency {}", collectionData);
changes = applyDelinquencyToLoanAndInstallments(loan, delinquencyBucket, collectionData, installmentsCollectionData);
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loan.getId())
.withEntityExternalId(loan.getExternalId()).with(changes).build();
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loan.getId()) //
.withEntityExternalId(loan.getExternalId()) //
.with(changes) //
.build(); //
}
@Override
@@ -210,16 +207,24 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
final CollectionData collectionData = loanDelinquentData.getLoanCollectionData();
// loan installments delinquent data
final Map<Long, CollectionData> installmentsCollectionData = loanDelinquentData.getLoanInstallmentsCollectionData();
// delinquency for installments
if (installmentsCollectionData.size() > 0) {
applyDelinquencyDetailsForLoanInstallments(loan, delinquencyBucket, installmentsCollectionData);
}
log.debug("Delinquency {}", collectionData);
// delinquency for loan
lookUpDelinquencyRange(loan, delinquencyBucket, collectionData.getDelinquentDays());
applyDelinquencyToLoanAndInstallments(loan, delinquencyBucket, collectionData, installmentsCollectionData);
}
}
private Map<String, Object> applyDelinquencyToLoanAndInstallments(Loan loan, DelinquencyBucket delinquencyBucket,
CollectionData collectionData, Map<Long, CollectionData> installmentsCollectionData) {
// Order is important: first calculate loan level delinquency, then the installment level
// delinquency for loan
Map<String, Object> result = delinquencyHelper.applyDelinquencyForLoan(loan, delinquencyBucket, collectionData.getDelinquentDays());
// delinquency for installments
if (!installmentsCollectionData.isEmpty()) {
delinquencyHelper.applyDelinquencyForLoanInstallments(loan, delinquencyBucket, installmentsCollectionData);
}
return result;
}
@Override
@Transactional
public CommandProcessingResult createDelinquencyAction(Long loanId, JsonCommand command) {
@@ -242,7 +247,8 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
}
}
businessEventNotifierService.notifyPostBusinessEvent(new LoanAccountDelinquencyPauseChangedBusinessEvent(loan));
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(saved.getId()) //
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
@@ -271,7 +277,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
if (loan.isEnableInstallmentLevelDelinquency()) {
cleanLoanInstallmentsDelinquencyTags(loan);
}
setLoanDelinquencyTag(loan, null);
delinquencyHelper.setLoanDelinquencyTag(loan, null);
}
@Override
@@ -365,7 +371,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
private void validateDelinquencyRanges(List<DelinquencyRange> ranges) {
// Sort the ranges based on the minAgeDays
ranges = sortDelinquencyRangesByMinAge(ranges);
ranges = delinquencyHelper.sortDelinquencyRangesByMinAge(ranges);
DelinquencyRange prevDelinquencyRange = null;
for (DelinquencyRange delinquencyRange : ranges) {
@@ -387,220 +393,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
}
}
private Map<String, Object> lookUpDelinquencyRange(final Loan loan, final DelinquencyBucket delinquencyBucket, long overdueDays) {
Map<String, Object> changes = new HashMap<>();
if (overdueDays <= 0) { // No Delinquency
log.debug("Loan {} without delinquency range with {} days", loan.getId(), overdueDays);
changes = setLoanDelinquencyTag(loan, null);
} else {
// Sort the ranges based on the minAgeDays
final List<DelinquencyRange> ranges = sortDelinquencyRangesByMinAge(delinquencyBucket.getRanges());
for (final DelinquencyRange delinquencyRange : ranges) {
if (delinquencyRange.getMaximumAgeDays() == null) { // Last Range in the Bucket
if (delinquencyRange.getMinimumAgeDays() <= overdueDays) {
log.debug("Loan {} with delinquency range {} with {} days", loan.getId(), delinquencyRange.getClassification(),
overdueDays);
changes = setLoanDelinquencyTag(loan, delinquencyRange.getId());
break;
}
} else {
if (delinquencyRange.getMinimumAgeDays() <= overdueDays && delinquencyRange.getMaximumAgeDays() >= overdueDays) {
log.debug("Loan {} with delinquency range {} with {} days", loan.getId(), delinquencyRange.getClassification(),
overdueDays);
changes = setLoanDelinquencyTag(loan, delinquencyRange.getId());
break;
}
}
}
}
changes.put("overdueDays", overdueDays);
return changes;
}
private Map<String, Object> setLoanDelinquencyTag(Loan loan, Long delinquencyRangeId) {
Map<String, Object> changes = new HashMap<>();
List<LoanDelinquencyTagHistory> loanDelinquencyTagHistory = new ArrayList<>();
final LocalDate transactionDate = DateUtils.getBusinessLocalDate();
Optional<LoanDelinquencyTagHistory> optLoanDelinquencyTag = this.loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(loan, null);
// The delinquencyRangeId in null means just goes out from Delinquency
LoanDelinquencyTagHistory loanDelinquencyTagPrev = null;
if (delinquencyRangeId == null) {
// The Loan will go out from Delinquency
if (optLoanDelinquencyTag.isPresent()) {
loanDelinquencyTagPrev = optLoanDelinquencyTag.get();
loanDelinquencyTagPrev.setLiftedOnDate(transactionDate);
loanDelinquencyTagHistory.add(loanDelinquencyTagPrev);
changes.put("previous", loanDelinquencyTagPrev.getDelinquencyRange());
// event when loan goes out of delinquency we do not calculate at
// installment level and remove all installment tags, so event needs to raised here.
if (loan.isEnableInstallmentLevelDelinquency()) {
businessEventNotifierService.notifyPostBusinessEvent(new LoanDelinquencyRangeChangeBusinessEvent(loan));
}
}
} else {
if (optLoanDelinquencyTag.isPresent()) {
loanDelinquencyTagPrev = optLoanDelinquencyTag.get();
}
// If the Delinquency Tag has not changed
if (loanDelinquencyTagPrev != null && loanDelinquencyTagPrev.getDelinquencyRange().getId().equals(delinquencyRangeId)) {
changes.put("current", loanDelinquencyTagPrev.getDelinquencyRange());
} else {
// The previous Loan Delinquency Tag will set as Lifted
if (loanDelinquencyTagPrev != null) {
loanDelinquencyTagPrev.setLiftedOnDate(transactionDate);
loanDelinquencyTagHistory.add(loanDelinquencyTagPrev);
changes.put("previous", loanDelinquencyTagPrev.getDelinquencyRange());
}
final DelinquencyRange delinquencyRange = repositoryRange.getReferenceById(delinquencyRangeId);
LoanDelinquencyTagHistory loanDelinquencyTag = new LoanDelinquencyTagHistory(delinquencyRange, loan, transactionDate, null);
loanDelinquencyTagHistory.add(loanDelinquencyTag);
changes.put("current", loanDelinquencyTag.getDelinquencyRange());
}
}
if (loanDelinquencyTagHistory.size() > 0) {
this.loanDelinquencyTagRepository.saveAllAndFlush(loanDelinquencyTagHistory);
// if installment level delinquency is enabled event will be raised at installment level calculation, no
// need to raise the event here
if (!loan.isEnableInstallmentLevelDelinquency()) {
businessEventNotifierService.notifyPostBusinessEvent(new LoanDelinquencyRangeChangeBusinessEvent(loan));
}
}
return changes;
}
private List<DelinquencyRange> sortDelinquencyRangesByMinAge(List<DelinquencyRange> ranges) {
final Comparator<DelinquencyRange> orderByMinAge = new Comparator<DelinquencyRange>() {
@Override
public int compare(DelinquencyRange o1, DelinquencyRange o2) {
return o1.getMinimumAgeDays().compareTo(o2.getMinimumAgeDays());
}
};
Collections.sort(ranges, orderByMinAge);
return ranges;
}
private void applyDelinquencyDetailsForLoanInstallments(final Loan loan, final DelinquencyBucket delinquencyBucket,
final Map<Long, CollectionData> installmentsCollectionData) {
boolean isDelinquencyRangeChangedForAnyOfInstallment = false;
for (LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) {
if (installmentsCollectionData.containsKey(installment.getId())) {
boolean isDelinquencySetForInstallment = setInstallmentDelinquencyDetails(loan, installment, delinquencyBucket,
installmentsCollectionData.get(installment.getId()));
isDelinquencyRangeChangedForAnyOfInstallment = isDelinquencyRangeChangedForAnyOfInstallment
|| isDelinquencySetForInstallment;
}
}
// remove tags for non existing installments that got deleted due to re-schedule
removeDelinquencyTagsForNonExistingInstallments(loan.getId());
// raise event if there is any change at installment level delinquency
if (isDelinquencyRangeChangedForAnyOfInstallment) {
businessEventNotifierService.notifyPostBusinessEvent(new LoanDelinquencyRangeChangeBusinessEvent(loan));
}
}
private boolean setInstallmentDelinquencyDetails(final Loan loan, final LoanRepaymentScheduleInstallment installment,
final DelinquencyBucket delinquencyBucket, final CollectionData installmentDelinquencyData) {
DelinquencyRange delinquencyRangeForInstallment = getInstallmentDelinquencyRange(delinquencyBucket,
installmentDelinquencyData.getDelinquentDays());
return setDelinquencyDetailsForInstallment(loan, installment, installmentDelinquencyData, delinquencyRangeForInstallment);
}
private DelinquencyRange getInstallmentDelinquencyRange(final DelinquencyBucket delinquencyBucket, Long overDueDays) {
DelinquencyRange delinquencyRangeForInstallment = null;
if (overDueDays > 0) {
// Sort the ranges based on the minAgeDays
final List<DelinquencyRange> ranges = sortDelinquencyRangesByMinAge(delinquencyBucket.getRanges());
for (final DelinquencyRange delinquencyRange : ranges) {
if (delinquencyRange.getMaximumAgeDays() == null) { // Last Range in the Bucket
if (delinquencyRange.getMinimumAgeDays() <= overDueDays) {
delinquencyRangeForInstallment = delinquencyRange;
break;
}
} else {
if (delinquencyRange.getMinimumAgeDays() <= overDueDays && delinquencyRange.getMaximumAgeDays() >= overDueDays) {
delinquencyRangeForInstallment = delinquencyRange;
break;
}
}
}
}
return delinquencyRangeForInstallment;
}
private boolean setDelinquencyDetailsForInstallment(final Loan loan, final LoanRepaymentScheduleInstallment installment,
CollectionData installmentDelinquencyData, final DelinquencyRange delinquencyRangeForInstallment) {
List<LoanInstallmentDelinquencyTag> installmentDelinquencyTags = new ArrayList<>();
LocalDate delinquencyCalculationDate = DateUtils.getBusinessLocalDate();
boolean isDelinquencyRangeChanged = false;
LoanInstallmentDelinquencyTag previousInstallmentDelinquencyTag = loanInstallmentDelinquencyTagRepository
.findByLoanAndInstallment(loan, installment).orElse(null);
if (delinquencyRangeForInstallment == null) {
// if currentInstallmentDelinquencyTag exists and range is null, installment is out of delinquency, delete
// delinquency details
if (previousInstallmentDelinquencyTag != null) {
// event installment out of delinquency
loanInstallmentDelinquencyTagRepository.delete(previousInstallmentDelinquencyTag);
isDelinquencyRangeChanged = true;
}
} else {
LoanInstallmentDelinquencyTag installmentDelinquency = null;
if (previousInstallmentDelinquencyTag != null) {
if (!previousInstallmentDelinquencyTag.getDelinquencyRange().getId().equals(delinquencyRangeForInstallment.getId())) {
// if current delinquency range exists and there is range change, delete previous delinquency
// details and add new range details
installmentDelinquency = new LoanInstallmentDelinquencyTag(delinquencyRangeForInstallment, loan, installment,
delinquencyCalculationDate, null, previousInstallmentDelinquencyTag.getFirstOverdueDate(),
installmentDelinquencyData.getDelinquentAmount());
loanInstallmentDelinquencyTagRepository.delete(previousInstallmentDelinquencyTag);
// event installment delinquency range change
isDelinquencyRangeChanged = true;
} else {
previousInstallmentDelinquencyTag.setOutstandingAmount(installmentDelinquencyData.getDelinquentAmount());
installmentDelinquency = previousInstallmentDelinquencyTag;
}
} else {
// add new range, first time delinquent
installmentDelinquency = new LoanInstallmentDelinquencyTag(delinquencyRangeForInstallment, loan, installment,
delinquencyCalculationDate, null, installmentDelinquencyData.getDelinquentDate(),
installmentDelinquencyData.getDelinquentAmount());
// event installment delinquent
isDelinquencyRangeChanged = true;
}
if (installmentDelinquency != null) {
installmentDelinquencyTags.add(installmentDelinquency);
}
}
if (installmentDelinquencyTags.size() > 0) {
loanInstallmentDelinquencyTagRepository.saveAllAndFlush(installmentDelinquencyTags);
}
return isDelinquencyRangeChanged;
}
private void cleanLoanInstallmentsDelinquencyTags(Loan loan) {
loanInstallmentDelinquencyTagRepository.deleteAllLoanInstallmentsTags(loan.getId());
}
private void removeDelinquencyTagsForNonExistingInstallments(Long loanId) {
List<LoanInstallmentDelinquencyTag> currentLoanInstallmentDelinquencyTags = loanInstallmentDelinquencyTagRepository
.findByLoanId(loanId);
if (currentLoanInstallmentDelinquencyTags != null && currentLoanInstallmentDelinquencyTags.size() > 0) {
List<Long> loanInstallmentTagsForDelete = currentLoanInstallmentDelinquencyTags.stream()
.filter(tag -> tag.getInstallment() == null).map(tag -> tag.getId()).toList();
if (loanInstallmentTagsForDelete.size() > 0) {
loanInstallmentDelinquencyTagRepository.deleteAllLoanInstallmentsTagsByIds(loanInstallmentTagsForDelete);
}
}
}
}
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper
import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformService;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformServiceImpl;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformService;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformServiceHelper;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformServiceImpl;
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainService;
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl;
@@ -76,11 +77,13 @@ public class DelinquencyConfiguration {
LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository,
DelinquencyReadPlatformService delinquencyReadPlatformService, LoanDelinquencyActionRepository loanDelinquencyActionRepository,
DelinquencyActionParseAndValidator delinquencyActionParseAndValidator,
DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper) {
DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper,
DelinquencyWritePlatformServiceHelper delinquencyWritePlatformServiceHelper) {
return new DelinquencyWritePlatformServiceImpl(dataValidatorBucket, dataValidatorRange, repositoryRange, repositoryBucket,
repositoryBucketMappings, loanDelinquencyTagRepository, loanRepository, loanProductRepository, businessEventNotifierService,
loanDelinquencyDomainService, loanInstallmentDelinquencyTagRepository, delinquencyReadPlatformService,
loanDelinquencyActionRepository, delinquencyActionParseAndValidator, delinquencyEffectivePauseHelper);
repositoryBucketMappings, loanDelinquencyTagRepository, loanRepository, loanProductRepository, loanDelinquencyDomainService,
loanInstallmentDelinquencyTagRepository, delinquencyReadPlatformService, loanDelinquencyActionRepository,
delinquencyActionParseAndValidator, delinquencyEffectivePauseHelper, businessEventNotifierService,
delinquencyWritePlatformServiceHelper);
}
@Bean
@@ -24,6 +24,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyIterable;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -62,6 +64,7 @@ import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquen
import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository;
import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformService;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformServiceHelper;
import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformServiceImpl;
import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainService;
import org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParseAndValidator;
@@ -81,7 +84,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -120,7 +123,8 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
@Mock
private DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper;
@InjectMocks
private DelinquencyWritePlatformServiceHelper delinquencyWritePlatformServiceHelper;
private DelinquencyWritePlatformServiceImpl underTest;
@BeforeEach
@@ -129,6 +133,14 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil
.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.now(ZoneId.systemDefault()))));
delinquencyWritePlatformServiceHelper = Mockito.spy(new DelinquencyWritePlatformServiceHelper(businessEventNotifierService,
loanDelinquencyTagRepository, repositoryRange, loanInstallmentDelinquencyTagRepository));
underTest = new DelinquencyWritePlatformServiceImpl(dataValidatorBucket, dataValidatorRange, repositoryRange, repositoryBucket,
repositoryBucketMappings, loanDelinquencyTagRepository, loanRepository, loanProductRepository, loanDelinquencyDomainService,
loanInstallmentDelinquencyTagRepository, delinquencyReadPlatformService, loanDelinquencyActionRepository,
delinquencyActionParseAndValidator, delinquencyEffectivePauseHelper, businessEventNotifierService,
delinquencyWritePlatformServiceHelper);
}
@AfterEach
@@ -180,6 +192,68 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
assertEquals(loanForProcessing, loanPayloadForEvent);
}
@Test
public void test_ApplyDelinquencyTagToLoan_ExecutesDelinquencyApplication_InTheRightOrder() {
// given
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
delinquencyBucket.setRanges(listDelinquencyRanges);
final Long daysDiff = 2L;
final LocalDate fromDate = DateUtils.getBusinessLocalDate().minusMonths(1).minusDays(daysDiff);
final LocalDate dueDate = DateUtils.getBusinessLocalDate().minusDays(daysDiff);
final BigDecimal installmentPrincipalAmount = BigDecimal.valueOf(100);
final BigDecimal zeroAmount = BigDecimal.ZERO;
LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loanForProcessing, 1, fromDate, dueDate,
installmentPrincipalAmount, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount);
installment.setId(1L);
List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments = Arrays.asList(installment);
LocalDate overDueSinceDate = DateUtils.getBusinessLocalDate().minusDays(2);
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);
CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate,
installmentPrincipalAmount, null, null, null, null, null, null);
Map<Long, CollectionData> installmentsCollection = new HashMap<>();
installmentsCollection.put(1L, installmentCollectionData);
LoanDelinquencyData loanDelinquencyData = new LoanDelinquencyData(collectionData, installmentsCollection);
when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct);
when(loanProduct.getDelinquencyBucket()).thenReturn(delinquencyBucket);
when(loanForProcessing.hasDelinquencyBucket()).thenReturn(true);
when(loanForProcessing.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments);
when(loanForProcessing.isEnableInstallmentLevelDelinquency()).thenReturn(true);
when(loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(any(), any())).thenReturn(Optional.empty());
when(loanDelinquencyDomainService.getLoanDelinquencyData(loanForProcessing, effectiveDelinquencyList))
.thenReturn(loanDelinquencyData);
when(loanInstallmentDelinquencyTagRepository.findByLoanAndInstallment(loanForProcessing, repaymentScheduleInstallments.get(0)))
.thenReturn(Optional.empty());
// when
underTest.applyDelinquencyTagToLoan(loanScheduleDelinquencyData, effectiveDelinquencyList);
// then
InOrder inOrder = inOrder(delinquencyWritePlatformServiceHelper);
inOrder.verify(delinquencyWritePlatformServiceHelper).applyDelinquencyForLoan(eq(loanForProcessing), eq(delinquencyBucket),
anyLong());
inOrder.verify(delinquencyWritePlatformServiceHelper).applyDelinquencyForLoanInstallments(eq(loanForProcessing),
eq(delinquencyBucket), eq(installmentsCollection));
}
@Test
public void givenLoanAccountWithDelinquencyBucketWhenNoRangeChangeThenNoEventIsRaised() {
// given