From 0f6c22f21ed4190ce49ea9b2674fc0fb2094332b Mon Sep 17 00:00:00 2001 From: Ruchi Dhamankar Date: Thu, 26 Oct 2023 20:25:07 +0530 Subject: [PATCH] FINERACT-1992-FINERACT-Installment-level-delinquency-calculation --- .../module/loan/module-changelog-master.xml | 1 + ...actor_loan_Installment_delinquency_tag.xml | 31 +++ .../LoanInstallmentDelinquencyTagData.java | 39 +++ .../domain/LoanInstallmentDelinquencyTag.java | 80 ++++++ ...anInstallmentDelinquencyTagRepository.java | 52 ++++ .../DelinquencyReadPlatformService.java | 3 + .../DelinquencyReadPlatformServiceImpl.java | 8 + .../DelinquencyWritePlatformServiceImpl.java | 133 +++++++++- .../service/LoanDelinquencyDomainService.java | 3 + .../LoanDelinquencyDomainServiceImpl.java | 233 ++++++++++++++---- .../starter/DelinquencyConfiguration.java | 12 +- .../loanaccount/data/LoanDelinquencyData.java | 35 +++ ...tePlatformServiceRangeChangeEventTest.java | 161 +++++++++++- .../LoanDelinquencyDomainServiceTest.java | 93 +++++++ 14 files changed, 827 insertions(+), 57 deletions(-) create mode 100644 fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1009_refactor_loan_Installment_delinquency_tag.xml create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/data/LoanInstallmentDelinquencyTagData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTag.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTagRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanDelinquencyData.java diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml index 27e5aea91e4..56a18e16e25 100644 --- a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/module-changelog-master.xml @@ -31,4 +31,5 @@ + diff --git a/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1009_refactor_loan_Installment_delinquency_tag.xml b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1009_refactor_loan_Installment_delinquency_tag.xml new file mode 100644 index 00000000000..62df114406b --- /dev/null +++ b/fineract-loan/src/main/resources/db/changelog/tenant/module/loan/parts/1009_refactor_loan_Installment_delinquency_tag.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/data/LoanInstallmentDelinquencyTagData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/data/LoanInstallmentDelinquencyTagData.java new file mode 100644 index 00000000000..860156951e7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/data/LoanInstallmentDelinquencyTagData.java @@ -0,0 +1,39 @@ +/** + * 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.data; + +import java.math.BigDecimal; + +public interface LoanInstallmentDelinquencyTagData { + + InstallmentDelinquencyRange getDelinquencyRange(); + + BigDecimal getOutstandingAmount(); + + interface InstallmentDelinquencyRange { + + Long getId(); + + String getClassification(); + + Integer getMinimumAgeDays(); + + Integer getMaximumAgeDays(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTag.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTag.java new file mode 100644 index 00000000000..248c49d5128 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTag.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "m_loan_installment_delinquency_tag") +public class LoanInstallmentDelinquencyTag extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne + @JoinColumn(name = "delinquency_range_id", nullable = false) + private DelinquencyRange delinquencyRange; + + @ManyToOne + @JoinColumn(name = "loan_id", nullable = false) + private Loan loan; + + @ManyToOne + @JoinColumn(name = "installment_id", nullable = false) + private LoanRepaymentScheduleInstallment installment; + + @Column(name = "addedon_date", nullable = false) + private LocalDate addedOnDate; + + @Column(name = "liftedon_date", nullable = true) + private LocalDate liftedOnDate; + + @Column(name = "first_overdue_date", nullable = false) + private LocalDate firstOverdueDate; + + @Column(name = "outstanding_amount", scale = 6, precision = 19) + private BigDecimal outstandingAmount; + + @Version + private Long version; + + public LoanInstallmentDelinquencyTag(DelinquencyRange delinquencyRange, Loan loan, LoanRepaymentScheduleInstallment installment, + LocalDate addedOnDate, LocalDate liftedOnDate, LocalDate firstOverdueDate, BigDecimal outstandingAmount) { + this.delinquencyRange = delinquencyRange; + this.loan = loan; + this.installment = installment; + this.addedOnDate = addedOnDate; + this.liftedOnDate = liftedOnDate; + this.firstOverdueDate = firstOverdueDate; + this.outstandingAmount = outstandingAmount; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTagRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTagRepository.java new file mode 100644 index 00000000000..5729fdec66e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/domain/LoanInstallmentDelinquencyTagRepository.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.domain; + +import java.util.List; +import java.util.Optional; +import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface LoanInstallmentDelinquencyTagRepository + extends JpaRepository, JpaSpecificationExecutor { + + Optional findByLoanAndInstallment(Loan loan, LoanRepaymentScheduleInstallment installment); + + @Query("select i from LoanInstallmentDelinquencyTag i where i.loan.id = :loanId") + List findByLoanId(@Param("loanId") Long loanId); + + // Fetching Installment Delinquency range and outstanding amount + @Query("select i.delinquencyRange, i.outstandingAmount from LoanInstallmentDelinquencyTag i where i.loan.id = :loanId") + List findInstallmentDelinquencyTags(@Param("loanId") Long loanId); + + @Modifying(flushAutomatically = true) + @Query("delete from LoanInstallmentDelinquencyTag i where i.loan.id = :loanId") + void deleteAllLoanInstallmentsTags(@Param("loanId") Long loanId); + + @Modifying(flushAutomatically = true) + @Query("delete from LoanInstallmentDelinquencyTag i where i.id IN :tagIds") + void deleteAllLoanInstallmentsTagsByIds(@Param("tagIds") List tagIds); + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformService.java index b1c2dad11c1..9f3510cce57 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformService.java @@ -22,6 +22,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.loanaccount.data.CollectionData; public interface DelinquencyReadPlatformService { @@ -40,4 +41,6 @@ public interface DelinquencyReadPlatformService { CollectionData calculateLoanCollectionData(Long loanId); + Collection retrieveLoanInstallmentsCurrentDelinquencyTag(Long loanId); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java index c98b493277a..7e1d9c1455e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java @@ -25,12 +25,14 @@ 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.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.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; @@ -52,6 +54,7 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo private final LoanDelinquencyTagMapper mapperLoanDelinquencyTagHistory; private final LoanRepository loanRepository; private final LoanDelinquencyDomainService loanDelinquencyDomainService; + private final LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag; @Override public Collection retrieveAllDelinquencyRanges() { @@ -126,4 +129,9 @@ public CollectionData calculateLoanCollectionData(final Long loanId) { return collectionData; } + @Override + public Collection retrieveLoanInstallmentsCurrentDelinquencyTag(Long loanId) { + return repositoryLoanInstallmentDelinquencyTag.findInstallmentDelinquencyTags(loanId); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java index 883a4780867..0520c36d156 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyWritePlatformServiceImpl.java @@ -46,13 +46,17 @@ 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.delinquency.exception.DelinquencyBucketAgesOverlapedException; import org.apache.fineract.portfolio.delinquency.exception.DelinquencyRangeInvalidAgesException; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyBucketParseAndValidator; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyRangeParseAndValidator; 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; @@ -71,6 +75,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat private final LoanProductRepository loanProductRepository; private final BusinessEventNotifierService businessEventNotifierService; private final LoanDelinquencyDomainService loanDelinquencyDomainService; + private final LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository; @Override public CommandProcessingResult createDelinquencyRange(JsonCommand command) { @@ -158,8 +163,17 @@ public CommandProcessingResult applyDelinquencyTagToLoan(Long loanId, JsonComman final Loan loan = this.loanRepository.findOneWithNotFoundDetection(loanId); final DelinquencyBucket delinquencyBucket = loan.getLoanProduct().getDelinquencyBucket(); if (delinquencyBucket != null) { - final CollectionData collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan); + final LoanDelinquencyData loanDelinquencyData = loanDelinquencyDomainService.getLoanDelinquencyData(loan); + // loan delinquent data + final CollectionData collectionData = loanDelinquencyData.getLoanCollectionData(); + // loan installments delinquent data + final Map installmentsCollectionData = loanDelinquencyData.getLoanInstallmentsCollectionData(); + // delinquency for loan changes = lookUpDelinquencyRange(loan, delinquencyBucket, collectionData.getDelinquentDays()); + // delinquency for installments + if (installmentsCollectionData.size() > 0) { + applyDelinquencyDetailsForLoanInstallments(loan, delinquencyBucket, installmentsCollectionData); + } } return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loan.getId()) .withEntityExternalId(loan.getExternalId()).with(changes).build(); @@ -170,15 +184,27 @@ public void applyDelinquencyTagToLoan(LoanScheduleDelinquencyData loanDelinquenc final Loan loan = loanDelinquencyData.getLoan(); if (loan.hasDelinquencyBucket()) { final DelinquencyBucket delinquencyBucket = loan.getLoanProduct().getDelinquencyBucket(); - final CollectionData collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan); + final LoanDelinquencyData loanDelinquentData = loanDelinquencyDomainService.getLoanDelinquencyData(loan); + // loan delinquent data + final CollectionData collectionData = loanDelinquentData.getLoanCollectionData(); + // loan installments delinquent data + final Map installmentsCollectionData = loanDelinquentData.getLoanInstallmentsCollectionData(); log.debug("Delinquency {}", collectionData); + // delinquency for loan lookUpDelinquencyRange(loan, delinquencyBucket, collectionData.getDelinquentDays()); + // delinquency for installments + if (installmentsCollectionData.size() > 0) { + applyDelinquencyDetailsForLoanInstallments(loan, delinquencyBucket, installmentsCollectionData); + } } } @Override public void removeDelinquencyTagToLoan(final Loan loan) { setLoanDelinquencyTag(loan, null); + if (loan.isEnableInstallmentLevelDelinquency()) { + cleanLoanInstallmentsDelinquencyTags(loan); + } } @Override @@ -382,4 +408,107 @@ public int compare(DelinquencyRange o1, DelinquencyRange o2) { return ranges; } + private void applyDelinquencyDetailsForLoanInstallments(final Loan loan, final DelinquencyBucket delinquencyBucket, + final Map installmentsCollectionData) { + for (LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) { + if (installmentsCollectionData.containsKey(installment.getId())) { + setInstallmentDelinquencyDetails(loan, installment, delinquencyBucket, installmentsCollectionData.get(installment.getId())); + } + } + // remove tags for non existing installments that got deleted due to re-schedule + removeDelinquencyTagsForNonExistingInstallments(loan.getId()); + } + + private void setInstallmentDelinquencyDetails(final Loan loan, final LoanRepaymentScheduleInstallment installment, + final DelinquencyBucket delinquencyBucket, final CollectionData installmentDelinquencyData) { + DelinquencyRange delinquencyRangeForInstallment = getInstallmentDelinquencyRange(delinquencyBucket, + installmentDelinquencyData.getDelinquentDays()); + 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 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 void setDelinquencyDetailsForInstallment(final Loan loan, final LoanRepaymentScheduleInstallment installment, + CollectionData installmentDelinquencyData, final DelinquencyRange delinquencyRangeForInstallment) { + List installmentDelinquencyTags = new ArrayList<>(); + LocalDate delinquencyCalculationDate = DateUtils.getBusinessLocalDate(); + + 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); + } + } 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 + } + } else { + // add new range, first time delinquent + installmentDelinquency = new LoanInstallmentDelinquencyTag(delinquencyRangeForInstallment, loan, installment, + delinquencyCalculationDate, null, installmentDelinquencyData.getDelinquentDate(), + installmentDelinquencyData.getDelinquentAmount()); + // event installment delinquent + } + + if (installmentDelinquency != null) { + installmentDelinquencyTags.add(installmentDelinquency); + } + + } + + if (installmentDelinquencyTags.size() > 0) { + loanInstallmentDelinquencyTagRepository.saveAllAndFlush(installmentDelinquencyTags); + } + + } + + private void cleanLoanInstallmentsDelinquencyTags(Loan loan) { + loanInstallmentDelinquencyTagRepository.deleteAllLoanInstallmentsTags(loan.getId()); + } + + private void removeDelinquencyTagsForNonExistingInstallments(Long loanId) { + List currentLoanInstallmentDelinquencyTags = loanInstallmentDelinquencyTagRepository + .findByLoanId(loanId); + if (currentLoanInstallmentDelinquencyTags != null && currentLoanInstallmentDelinquencyTags.size() > 0) { + List loanInstallmentTagsForDelete = currentLoanInstallmentDelinquencyTags.stream() + .filter(tag -> tag.getInstallment() == null).map(tag -> tag.getId()).toList(); + if (loanInstallmentTagsForDelete.size() > 0) { + loanInstallmentDelinquencyTagRepository.deleteAllLoanInstallmentsTagsByIds(loanInstallmentTagsForDelete); + } + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainService.java index 5cd19cb5f3b..656ceb6b7ae 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainService.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.delinquency.service; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; +import org.apache.fineract.portfolio.loanaccount.data.LoanDelinquencyData; import org.apache.fineract.portfolio.loanaccount.domain.Loan; public interface LoanDelinquencyDomainService { @@ -31,4 +32,6 @@ public interface LoanDelinquencyDomainService { */ CollectionData getOverdueCollectionData(Loan loan); + LoanDelinquencyData getLoanDelinquencyData(Loan loan); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java index 343eeabdb51..67085cd6503 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java @@ -20,12 +20,15 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; +import org.apache.fineract.portfolio.loanaccount.data.LoanDelinquencyData; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; @@ -42,15 +45,10 @@ public CollectionData getOverdueCollectionData(final Loan loan) { final MonetaryCurrency loanCurrency = loan.getCurrency(); LocalDate overdueSinceDate = null; CollectionData collectionData = CollectionData.template(); - BigDecimal amountAvailable; BigDecimal outstandingAmount = BigDecimal.ZERO; boolean oldestOverdueInstallment = false; boolean overdueSinceDateWasSet = false; boolean firstNotYetDueInstallment = false; - LoanRepaymentScheduleInstallment latestInstallment = loan.getLastLoanRepaymentScheduleInstallment(); - - List chargebackTransactions = loan.getLoanTransactions(LoanTransaction::isChargeback); - log.debug("Loan id {} with {} installments", loan.getId(), loan.getRepaymentScheduleInstallments().size()); // Get the oldest overdue installment if exists one for (LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) { @@ -61,61 +59,90 @@ public CollectionData getOverdueCollectionData(final Loan loan) { outstandingAmount = outstandingAmount.add(installment.getTotalOutstanding(loanCurrency).getAmount()); if (!oldestOverdueInstallment) { log.debug("Oldest installment {} {}", installment.getInstallmentNumber(), installment.getDueDate()); + CollectionData overDueInstallmentDelinquentData = calculateDelinquencyDataForOverdueInstallment(loan, installment); + overdueSinceDate = overDueInstallmentDelinquentData.getDelinquentDate(); oldestOverdueInstallment = true; - overdueSinceDate = installment.getDueDate(); overdueSinceDateWasSet = true; - - amountAvailable = installment.getTotalPaid(loanCurrency).getAmount(); - - boolean isLatestInstallment = Objects.equals(installment.getId(), latestInstallment.getId()); - for (LoanTransaction loanTransaction : chargebackTransactions) { - boolean isLoanTransactionIsOnOrAfterInstallmentFromDate = DateUtils - .isEqual(loanTransaction.getTransactionDate(), installment.getFromDate()) - || DateUtils.isAfter(loanTransaction.getTransactionDate(), installment.getFromDate()); - boolean isLoanTransactionIsBeforeNotLastInstallmentDueDate = !isLatestInstallment - && DateUtils.isBefore(loanTransaction.getTransactionDate(), installment.getDueDate()); - boolean isLoanTransactionIsOnOrBeforeLastInstallmentDueDate = isLatestInstallment - && !DateUtils.isAfter(loanTransaction.getTransactionDate(), installment.getDueDate()); - if (isLoanTransactionIsOnOrAfterInstallmentFromDate && (isLoanTransactionIsBeforeNotLastInstallmentDueDate - || isLoanTransactionIsOnOrBeforeLastInstallmentDueDate)) { - amountAvailable = amountAvailable.subtract(loanTransaction.getAmount()); - if (amountAvailable.compareTo(BigDecimal.ZERO) < 0) { - overdueSinceDate = loanTransaction.getTransactionDate(); - break; - } - } - } } } else if (!firstNotYetDueInstallment) { log.debug("Loan Id: {} with installment {} due date {}", loan.getId(), installment.getInstallmentNumber(), installment.getDueDate()); firstNotYetDueInstallment = true; - amountAvailable = installment.getTotalPaid(loanCurrency).getAmount(); - log.debug("Amount available {}", amountAvailable); - for (LoanTransaction loanTransaction : chargebackTransactions) { - boolean isLoanTransactionIsOnOrAfterInstallmentFromDate = !DateUtils.isBefore(loanTransaction.getTransactionDate(), - installment.getFromDate()); - boolean isLoanTransactionIsBeforeInstallmentDueDate = DateUtils.isBefore(loanTransaction.getTransactionDate(), - installment.getDueDate()); - boolean isLoanTransactionIsBeforeBusinessDate = DateUtils.isBefore(loanTransaction.getTransactionDate(), - businessDate); - if (isLoanTransactionIsOnOrAfterInstallmentFromDate && isLoanTransactionIsBeforeInstallmentDueDate - && isLoanTransactionIsBeforeBusinessDate) { - log.debug("Loan CB Transaction: {} {} {}", loanTransaction.getId(), loanTransaction.getTransactionDate(), - loanTransaction.getAmount()); - amountAvailable = amountAvailable.subtract(loanTransaction.getAmount()); - if (amountAvailable.compareTo(BigDecimal.ZERO) < 0 && !overdueSinceDateWasSet) { - overdueSinceDate = loanTransaction.getTransactionDate(); - overdueSinceDateWasSet = true; - } - } + CollectionData nonOverDueInstallmentDelinquentData = calculateDelinquencyDataForNonOverdueInstallment(loan, + installment); + outstandingAmount = outstandingAmount.add(nonOverDueInstallmentDelinquentData.getDelinquentAmount()); + if (!overdueSinceDateWasSet) { + overdueSinceDate = nonOverDueInstallmentDelinquentData.getDelinquentDate(); + overdueSinceDateWasSet = true; } + } + } + } + + Integer graceDays = 0; + if (loan.getLoanProductRelatedDetail().getGraceOnArrearsAgeing() != null) { + graceDays = loan.getLoanProductRelatedDetail().getGraceOnArrearsAgeing(); + } + log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount); - if (amountAvailable.compareTo(BigDecimal.ZERO) < 0) { - outstandingAmount = outstandingAmount.add(amountAvailable.abs()); + Long overdueDays = 0L; + if (overdueSinceDate != null) { + overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate); + if (overdueDays < 0) { + overdueDays = 0L; + } + collectionData.setPastDueDays(overdueDays); + overdueSinceDate = overdueSinceDate.plusDays(graceDays.longValue()); + collectionData.setDelinquentDate(overdueSinceDate); + } + collectionData.setDelinquentAmount(outstandingAmount); + collectionData.setDelinquentDays(0L); + Long delinquentDays = overdueDays - graceDays; + if (delinquentDays > 0) { + collectionData.setDelinquentDays(delinquentDays); + } + + log.debug("Result: {}", collectionData.toString()); + return collectionData; + } + + @Override + public LoanDelinquencyData getLoanDelinquencyData(final Loan loan) { + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + LocalDate overdueSinceDate = null; + CollectionData collectionData = CollectionData.template(); + Map loanInstallmentsCollectionData = new HashMap<>(); + BigDecimal outstandingAmount = BigDecimal.ZERO; + boolean oldestOverdueInstallment = false; + boolean overdueSinceDateWasSet = false; + boolean firstNotYetDueInstallment = false; + log.debug("Loan id {} with {} installments", loan.getId(), loan.getRepaymentScheduleInstallments().size()); + for (LoanRepaymentScheduleInstallment installment : loan.getRepaymentScheduleInstallments()) { + CollectionData installmentCollectionData = CollectionData.template(); + if (!installment.isObligationsMet()) { + installmentCollectionData = getInstallmentOverdueCollectionData(loan, installment); + outstandingAmount = outstandingAmount.add(installmentCollectionData.getDelinquentAmount()); + // Get the oldest overdue installment if exists + if (DateUtils.isBefore(installment.getDueDate(), businessDate)) { + if (!oldestOverdueInstallment) { + overdueSinceDate = installmentCollectionData.getDelinquentDate(); + oldestOverdueInstallment = true; + overdueSinceDateWasSet = true; + } + } else if (!firstNotYetDueInstallment) { + firstNotYetDueInstallment = true; + if (!overdueSinceDateWasSet) { + overdueSinceDate = installmentCollectionData.getDelinquentDate(); + overdueSinceDateWasSet = true; } } } + // if installment level delinquency enabled add delinquency data for installment + if (loan.isEnableInstallmentLevelDelinquency()) { + loanInstallmentsCollectionData.put(installment.getId(), installmentCollectionData); + } + } Integer graceDays = 0; @@ -140,8 +167,116 @@ public CollectionData getOverdueCollectionData(final Loan loan) { if (delinquentDays > 0) { collectionData.setDelinquentDays(delinquentDays); } + return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData); + } - log.debug("Result: {}", collectionData.toString()); + private CollectionData getInstallmentOverdueCollectionData(final Loan loan, final LoanRepaymentScheduleInstallment installment) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + LocalDate overdueSinceDate = null; + CollectionData collectionData = CollectionData.template(); + BigDecimal outstandingAmount = BigDecimal.ZERO; + if (DateUtils.isBefore(installment.getDueDate(), businessDate)) { + // checking overdue installment delinquency data + CollectionData overDueInstallmentDelinquentData = calculateDelinquencyDataForOverdueInstallment(loan, installment); + outstandingAmount = outstandingAmount.add(overDueInstallmentDelinquentData.getDelinquentAmount()); + overdueSinceDate = overDueInstallmentDelinquentData.getDelinquentDate(); + + } else { + // checking non overdue installment for chargeback transactions before installment due date and before + // business date + CollectionData nonOverDueInstallmentDelinquentData = calculateDelinquencyDataForNonOverdueInstallment(loan, installment); + outstandingAmount = outstandingAmount.add(nonOverDueInstallmentDelinquentData.getDelinquentAmount()); + overdueSinceDate = nonOverDueInstallmentDelinquentData.getDelinquentDate(); + } + + // Grace days are not considered for installment level delinquency calculation currently. + + Long overdueDays = 0L; + if (overdueSinceDate != null) { + // TODO : Changes for considering paused delinquency days for overdue days calculation + overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate); + if (overdueDays < 0) { + overdueDays = 0L; + } + collectionData.setPastDueDays(overdueDays); + collectionData.setDelinquentDate(overdueSinceDate); + } + collectionData.setDelinquentAmount(outstandingAmount); + collectionData.setDelinquentDays(0L); + Long delinquentDays = overdueDays; + if (delinquentDays > 0) { + collectionData.setDelinquentDays(delinquentDays); + } + return collectionData; + + } + + private CollectionData calculateDelinquencyDataForOverdueInstallment(final Loan loan, + final LoanRepaymentScheduleInstallment installment) { + final MonetaryCurrency loanCurrency = loan.getCurrency(); + LoanRepaymentScheduleInstallment latestInstallment = loan.getLastLoanRepaymentScheduleInstallment(); + List chargebackTransactions = loan.getLoanTransactions(LoanTransaction::isChargeback); + LocalDate overdueSinceDate = null; + CollectionData collectionData = CollectionData.template(); + BigDecimal outstandingAmount = BigDecimal.ZERO; + + outstandingAmount = outstandingAmount.add(installment.getTotalOutstanding(loanCurrency).getAmount()); + overdueSinceDate = installment.getDueDate(); + BigDecimal amountAvailable = installment.getTotalPaid(loanCurrency).getAmount(); + boolean isLatestInstallment = Objects.equals(installment.getId(), latestInstallment.getId()); + for (LoanTransaction loanTransaction : chargebackTransactions) { + boolean isLoanTransactionIsOnOrAfterInstallmentFromDate = DateUtils.isEqual(loanTransaction.getTransactionDate(), + installment.getFromDate()) || DateUtils.isAfter(loanTransaction.getTransactionDate(), installment.getFromDate()); + boolean isLoanTransactionIsBeforeNotLastInstallmentDueDate = !isLatestInstallment + && DateUtils.isBefore(loanTransaction.getTransactionDate(), installment.getDueDate()); + boolean isLoanTransactionIsOnOrBeforeLastInstallmentDueDate = isLatestInstallment + && (DateUtils.isEqual(loanTransaction.getTransactionDate(), installment.getDueDate()) + || DateUtils.isBefore(loanTransaction.getTransactionDate(), installment.getDueDate())); + if (isLoanTransactionIsOnOrAfterInstallmentFromDate + && (isLoanTransactionIsBeforeNotLastInstallmentDueDate || isLoanTransactionIsOnOrBeforeLastInstallmentDueDate)) { + amountAvailable = amountAvailable.subtract(loanTransaction.getAmount()); + if (amountAvailable.compareTo(BigDecimal.ZERO) < 0) { + overdueSinceDate = loanTransaction.getTransactionDate(); + break; + } + } + } + collectionData.setDelinquentDate(overdueSinceDate); + collectionData.setDelinquentAmount(outstandingAmount); + return collectionData; + } + + private CollectionData calculateDelinquencyDataForNonOverdueInstallment(final Loan loan, + final LoanRepaymentScheduleInstallment installment) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final MonetaryCurrency loanCurrency = loan.getCurrency(); + + LocalDate overdueSinceDate = null; + CollectionData collectionData = CollectionData.template(); + BigDecimal outstandingAmount = BigDecimal.ZERO; + + List chargebackTransactions = loan.getLoanTransactions(LoanTransaction::isChargeback); + BigDecimal amountAvailable = installment.getTotalPaid(loanCurrency).getAmount(); + for (LoanTransaction loanTransaction : chargebackTransactions) { + + boolean isLoanTransactionIsOnOrAfterInstallmentFromDate = DateUtils.isEqual(loanTransaction.getTransactionDate(), + installment.getFromDate()) || DateUtils.isAfter(loanTransaction.getTransactionDate(), installment.getFromDate()); + boolean isLoanTransactionIsBeforeInstallmentDueDate = DateUtils.isBefore(loanTransaction.getTransactionDate(), + installment.getDueDate()); + boolean isLoanTransactionIsBeforeBusinessDate = DateUtils.isBefore(loanTransaction.getTransactionDate(), businessDate); + if (isLoanTransactionIsOnOrAfterInstallmentFromDate && isLoanTransactionIsBeforeInstallmentDueDate + && isLoanTransactionIsBeforeBusinessDate) { + amountAvailable = amountAvailable.subtract(loanTransaction.getAmount()); + if (amountAvailable.compareTo(BigDecimal.ZERO) < 0) { + overdueSinceDate = loanTransaction.getTransactionDate(); + } + } + } + if (amountAvailable.compareTo(BigDecimal.ZERO) < 0) { + outstandingAmount = outstandingAmount.add(amountAvailable.abs()); + } + collectionData.setDelinquentDate(overdueSinceDate); + collectionData.setDelinquentAmount(outstandingAmount); return collectionData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java index 40131394bbf..88c27afa8c6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java @@ -23,6 +23,7 @@ import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository; 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; @@ -50,9 +51,11 @@ public DelinquencyReadPlatformService delinquencyReadPlatformService(Delinquency DelinquencyBucketRepository repositoryBucket, LoanDelinquencyTagHistoryRepository repositoryLoanDelinquencyTagHistory, DelinquencyRangeMapper mapperRange, DelinquencyBucketMapper mapperBucket, LoanDelinquencyTagMapper mapperLoanDelinquencyTagHistory, LoanRepository loanRepository, - LoanDelinquencyDomainService loanDelinquencyDomainService) { + LoanDelinquencyDomainService loanDelinquencyDomainService, + LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag) { return new DelinquencyReadPlatformServiceImpl(repositoryRange, repositoryBucket, repositoryLoanDelinquencyTagHistory, mapperRange, - mapperBucket, mapperLoanDelinquencyTagHistory, loanRepository, loanDelinquencyDomainService); + mapperBucket, mapperLoanDelinquencyTagHistory, loanRepository, loanDelinquencyDomainService, + repositoryLoanInstallmentDelinquencyTag); } @Bean @@ -62,10 +65,11 @@ public DelinquencyWritePlatformService delinquencyWritePlatformService(Delinquen DelinquencyBucketRepository repositoryBucket, DelinquencyBucketMappingsRepository repositoryBucketMappings, LoanDelinquencyTagHistoryRepository loanDelinquencyTagRepository, LoanRepositoryWrapper loanRepository, LoanProductRepository loanProductRepository, BusinessEventNotifierService businessEventNotifierService, - LoanDelinquencyDomainService loanDelinquencyDomainService) { + LoanDelinquencyDomainService loanDelinquencyDomainService, + LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository) { return new DelinquencyWritePlatformServiceImpl(dataValidatorBucket, dataValidatorRange, repositoryRange, repositoryBucket, repositoryBucketMappings, loanDelinquencyTagRepository, loanRepository, loanProductRepository, businessEventNotifierService, - loanDelinquencyDomainService); + loanDelinquencyDomainService, loanInstallmentDelinquencyTagRepository); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanDelinquencyData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanDelinquencyData.java new file mode 100644 index 00000000000..3efaea390c1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanDelinquencyData.java @@ -0,0 +1,35 @@ +/** + * 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.loanaccount.data; + +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@AllArgsConstructor +@ToString +@Getter +@Setter +public class LoanDelinquencyData { + + private CollectionData loanCollectionData; + private Map loanInstallmentsCollectionData; +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java index 866c87d4ae8..ab85206e2ee 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/DelinquencyWritePlatformServiceRangeChangeEventTest.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.deliquency; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyIterable; import static org.mockito.Mockito.times; @@ -30,6 +31,7 @@ import java.time.ZoneId; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -46,13 +48,17 @@ import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRange; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository; 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.service.DelinquencyWritePlatformServiceImpl; import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainService; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyBucketParseAndValidator; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyRangeParseAndValidator; 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.LoanProduct; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; @@ -88,6 +94,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { private BusinessEventNotifierService businessEventNotifierService; @Mock private LoanDelinquencyDomainService loanDelinquencyDomainService; + @Mock + private LoanInstallmentDelinquencyTagRepository loanInstallmentDelinquencyTagRepository; + @InjectMocks private DelinquencyWritePlatformServiceImpl underTest; @@ -120,11 +129,15 @@ public void givenLoanAccountWithDelinquencyBucketWhenRangeChangeThenEventIsRaise CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, BigDecimal.ZERO, null, null, null, null); + Map installmentsCollection = new HashMap<>(); + + LoanDelinquencyData loanDelinquencyData = new LoanDelinquencyData(collectionData, installmentsCollection); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); when(loanProduct.getDelinquencyBucket()).thenReturn(delinquencyBucket); when(loanForProcessing.hasDelinquencyBucket()).thenReturn(true); when(loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(any(), any())).thenReturn(Optional.empty()); - when(loanDelinquencyDomainService.getOverdueCollectionData(loanForProcessing)).thenReturn(collectionData); + when(loanDelinquencyDomainService.getLoanDelinquencyData(loanForProcessing)).thenReturn(loanDelinquencyData); // when underTest.applyDelinquencyTagToLoan(loanScheduleDelinquencyData); @@ -155,11 +168,17 @@ public void givenLoanAccountWithDelinquencyBucketWhenNoRangeChangeThenNoEventIsR LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 2L, loanForProcessing); + CollectionData collectionData = CollectionData.template(); + + Map installmentsCollection = new HashMap<>(); + + LoanDelinquencyData loanDelinquencyData = new LoanDelinquencyData(collectionData, installmentsCollection); + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); when(loanProduct.getDelinquencyBucket()).thenReturn(delinquencyBucket); when(loanForProcessing.hasDelinquencyBucket()).thenReturn(true); when(loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(any(), any())).thenReturn(Optional.empty()); - when(loanDelinquencyDomainService.getOverdueCollectionData(loanForProcessing)).thenReturn(CollectionData.template()); + when(loanDelinquencyDomainService.getLoanDelinquencyData(loanForProcessing)).thenReturn(loanDelinquencyData); // when underTest.applyDelinquencyTagToLoan(loanScheduleDelinquencyData); @@ -189,4 +208,142 @@ public void givenLoanAccountWithNoDelinquencyBucketThenNoEventIsRaised() { } + @Test + public void givenLoanAccountWithOverdueInstallmentAndEnableInstallmentThenDelinquencyRangeIsSetForInstallmentTest() { + ArgumentCaptor> loanInstallmentDelinquencyTagsArgumentCaptor = ArgumentCaptor + .forClass(List.class); + // given + 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 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 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); + + CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 2L, null, 2L, overDueSinceDate, + installmentPrincipalAmount, null, null, null, null); + + Map 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(loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(any(), any())).thenReturn(Optional.empty()); + when(loanDelinquencyDomainService.getLoanDelinquencyData(loanForProcessing)).thenReturn(loanDelinquencyData); + when(loanInstallmentDelinquencyTagRepository.findByLoanAndInstallment(loanForProcessing, repaymentScheduleInstallments.get(0))) + .thenReturn(Optional.empty()); + + // when + underTest.applyDelinquencyTagToLoan(loanScheduleDelinquencyData); + + // then + verify(loanDelinquencyTagRepository, times(1)).saveAllAndFlush(anyIterable()); + verify(loanInstallmentDelinquencyTagRepository, times(1)).saveAllAndFlush(loanInstallmentDelinquencyTagsArgumentCaptor.capture()); + + List installmentDelinquencyTags = loanInstallmentDelinquencyTagsArgumentCaptor.getValue(); + assertEquals(1, installmentDelinquencyTags.size()); + assertEquals(1, installmentDelinquencyTags.get(0).getInstallment().getInstallmentNumber()); + assertEquals(1, installmentDelinquencyTags.get(0).getDelinquencyRange().getId()); + assertEquals(installmentPrincipalAmount, installmentDelinquencyTags.get(0).getOutstandingAmount()); + } + + @Test + public void givenLoanAccountWithOverdueInstallmentAndEnableInstallmentThenDelinquencyRangeChangesForInstallmentTest() { + ArgumentCaptor> loanInstallmentDelinquencyTagsArgumentCaptor = ArgumentCaptor + .forClass(List.class); + ArgumentCaptor loanInstallmentDelinquencyTagArgumentCaptorForDelete = ArgumentCaptor + .forClass(LoanInstallmentDelinquencyTag.class); + // given + 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 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 repaymentScheduleInstallments = Arrays.asList(installment); + + LocalDate overDueSinceDate = DateUtils.getBusinessLocalDate().minusDays(29); + LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, + loanForProcessing); + CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate, BigDecimal.ZERO, null, null, + null, null); + + CollectionData installmentCollectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate, + installmentPrincipalAmount, null, null, null, null); + + Map installmentsCollection = new HashMap<>(); + installmentsCollection.put(1L, installmentCollectionData); + + LoanDelinquencyData loanDelinquencyData = new LoanDelinquencyData(collectionData, installmentsCollection); + + LoanInstallmentDelinquencyTag previousInstallmentTag = new LoanInstallmentDelinquencyTag(); + previousInstallmentTag.setDelinquencyRange(range1); + + when(loanForProcessing.getLoanProduct()).thenReturn(loanProduct); + when(loanProduct.getDelinquencyBucket()).thenReturn(delinquencyBucket); + when(loanForProcessing.hasDelinquencyBucket()).thenReturn(true); + when(loanForProcessing.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments); + when(loanDelinquencyTagRepository.findByLoanAndLiftedOnDate(any(), any())).thenReturn(Optional.empty()); + when(loanDelinquencyDomainService.getLoanDelinquencyData(loanForProcessing)).thenReturn(loanDelinquencyData); + when(loanInstallmentDelinquencyTagRepository.findByLoanAndInstallment(loanForProcessing, repaymentScheduleInstallments.get(0))) + .thenReturn(Optional.of(previousInstallmentTag)); + + // when + underTest.applyDelinquencyTagToLoan(loanScheduleDelinquencyData); + + // then + verify(loanDelinquencyTagRepository, times(1)).saveAllAndFlush(anyIterable()); + verify(loanInstallmentDelinquencyTagRepository, times(1)).saveAllAndFlush(loanInstallmentDelinquencyTagsArgumentCaptor.capture()); + verify(loanInstallmentDelinquencyTagRepository, times(1)).delete(loanInstallmentDelinquencyTagArgumentCaptorForDelete.capture()); + + List installmentDelinquencyTags = loanInstallmentDelinquencyTagsArgumentCaptor.getValue(); + assertEquals(1, installmentDelinquencyTags.size()); + assertEquals(1, installmentDelinquencyTags.get(0).getInstallment().getInstallmentNumber()); + assertEquals(2, installmentDelinquencyTags.get(0).getDelinquencyRange().getId()); + assertEquals(installmentPrincipalAmount, installmentDelinquencyTags.get(0).getOutstandingAmount()); + + LoanInstallmentDelinquencyTag deletedInstallmentDelinquencyTag = loanInstallmentDelinquencyTagArgumentCaptorForDelete.getValue(); + assertNotNull(deletedInstallmentDelinquencyTag); + assertEquals(previousInstallmentTag, deletedInstallmentDelinquencyTag); + + } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java index c75580724cb..047f7a08d50 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.deliquency; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.when; import java.math.BigDecimal; @@ -43,6 +44,7 @@ import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; +import org.apache.fineract.portfolio.loanaccount.data.LoanDelinquencyData; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; @@ -185,4 +187,95 @@ public void givenLoanAccountWithoutOverdueWithChargebackThenCalculateDelinquentD } + @Test + public void givenLoanInstallmentWithOverdueEnableInstallmentDelinquencyThenCalculateDelinquentData() { + // given + final Long daysDiff = 2L; + final LocalDate fromDate = businessDate.minusMonths(1).minusDays(daysDiff); + final LocalDate dueDate = businessDate.minusDays(daysDiff); + + LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, 1, fromDate, dueDate, principal, + zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installment.setId(1L); + List repaymentScheduleInstallments = Arrays.asList(installment); + + // when + when(loanProductRelatedDetail.getGraceOnArrearsAgeing()).thenReturn(0); + when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductRelatedDetail); + when(loan.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments); + when(loan.getLoanTransactions(Mockito.any(Predicate.class))).thenReturn(Collections.emptyList()); + when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(repaymentScheduleInstallments.get(0)); + when(loan.getCurrency()).thenReturn(currency); + when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true); + + LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan); + + // then + assertNotNull(collectionData); + assertNotNull(collectionData.getLoanInstallmentsCollectionData()); + assertEquals(1L, collectionData.getLoanInstallmentsCollectionData().size()); + + CollectionData loanCollectionData = collectionData.getLoanCollectionData(); + CollectionData installmentCollectionData = collectionData.getLoanInstallmentsCollectionData().get(1L); + + assertEquals(daysDiff, loanCollectionData.getDelinquentDays()); + assertEquals(dueDate, loanCollectionData.getDelinquentDate()); + assertEquals(loanCollectionData.getDelinquentDays(), loanCollectionData.getPastDueDays()); + + assertEquals(daysDiff, installmentCollectionData.getDelinquentDays()); + assertEquals(dueDate, installmentCollectionData.getDelinquentDate()); + assertEquals(installmentCollectionData.getDelinquentDays(), installmentCollectionData.getPastDueDays()); + + } + + @Test + public void givenLoanInstallmentWithoutOverdueWithChargebackAndEnableInstallmentDelinquencyThenCalculateDelinquentData() { + + // given + PaymentDetail paymentDetail = Mockito.mock(PaymentDetail.class); + Long daysDiff = 2L; + final LocalDate fromDate = businessDate.minusMonths(1).plusDays(daysDiff); + final LocalDate dueDate = businessDate.plusDays(daysDiff); + final LocalDate transactionDate = businessDate.minusDays(daysDiff); + + final Money zeroMoney = Money.zero(currency); + LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, 1, fromDate, dueDate, principal, + zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installment.setId(1L); + LoanTransaction loanTransaction = LoanTransaction.chargeback(loan, Money.of(currency, principal), paymentDetail, transactionDate, + null); + installment.getLoanTransactionToRepaymentScheduleMappings().add(LoanTransactionToRepaymentScheduleMapping + .createFrom(loanTransaction, installment, zeroMoney, zeroMoney, zeroMoney, zeroMoney)); + + List repaymentScheduleInstallments = Arrays.asList(installment); + // when + when(loanProductRelatedDetail.getGraceOnArrearsAgeing()).thenReturn(0); + when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductRelatedDetail); + when(loan.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments); + when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true); + when(loan.getCurrency()).thenReturn(currency); + when(loan.getLoanTransactions(Mockito.any(Predicate.class))).thenReturn(Arrays.asList(loanTransaction)); + + LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan); + + // then + assertNotNull(collectionData); + assertNotNull(collectionData.getLoanInstallmentsCollectionData()); + assertEquals(1L, collectionData.getLoanInstallmentsCollectionData().size()); + + CollectionData loanCollectionData = collectionData.getLoanCollectionData(); + CollectionData installmentCollectionData = collectionData.getLoanInstallmentsCollectionData().get(1L); + + assertEquals(daysDiff, loanCollectionData.getDelinquentDays()); + assertEquals(transactionDate, loanCollectionData.getDelinquentDate()); + assertEquals(loanCollectionData.getDelinquentDays(), loanCollectionData.getPastDueDays()); + + // then + assertEquals(daysDiff, installmentCollectionData.getDelinquentDays()); + assertEquals(transactionDate, installmentCollectionData.getDelinquentDate()); + assertEquals(installmentCollectionData.getDelinquentDays(), installmentCollectionData.getPastDueDays()); + assertEquals(0, principal.compareTo(installmentCollectionData.getDelinquentAmount())); + + } + }