FINERACT-1992-FINERACT-Installment-level-delinquency-calculation

This commit is contained in:
Ruchi Dhamankar
2023-10-27 14:31:59 +02:00
committed by Adam Saghy
parent 585d200c67
commit 8cea837a5b
14 changed files with 828 additions and 58 deletions
@@ -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();
}
}
@@ -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;
}
}
@@ -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<LoanInstallmentDelinquencyTag, Long>, JpaSpecificationExecutor<LoanInstallmentDelinquencyTag> {
Optional<LoanInstallmentDelinquencyTag> findByLoanAndInstallment(Loan loan, LoanRepaymentScheduleInstallment installment);
@Query("select i from LoanInstallmentDelinquencyTag i where i.loan.id = :loanId")
List<LoanInstallmentDelinquencyTag> 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<LoanInstallmentDelinquencyTagData> 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<Long> tagIds);
}
@@ -22,6 +22,7 @@ import java.util.Collection;
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<LoanInstallmentDelinquencyTagData> retrieveLoanInstallmentsCurrentDelinquencyTag(Long loanId);
}
@@ -25,12 +25,14 @@ import lombok.RequiredArgsConstructor;
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<DelinquencyRangeData> retrieveAllDelinquencyRanges() {
@@ -126,4 +129,9 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
return collectionData;
}
@Override
public Collection<LoanInstallmentDelinquencyTagData> retrieveLoanInstallmentsCurrentDelinquencyTag(Long loanId) {
return repositoryLoanInstallmentDelinquencyTag.findInstallmentDelinquencyTags(loanId);
}
}
@@ -46,13 +46,17 @@ 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.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 class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
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<Long, CollectionData> 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 class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
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<Long, CollectionData> 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 class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
return ranges;
}
private void applyDelinquencyDetailsForLoanInstallments(final Loan loan, final DelinquencyBucket delinquencyBucket,
final Map<Long, CollectionData> 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<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 void setDelinquencyDetailsForInstallment(final Loan loan, final LoanRepaymentScheduleInstallment installment,
CollectionData installmentDelinquencyData, final DelinquencyRange delinquencyRangeForInstallment) {
List<LoanInstallmentDelinquencyTag> 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<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);
}
}
}
}
@@ -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);
}
@@ -20,12 +20,15 @@ package org.apache.fineract.portfolio.delinquency.service;
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 class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
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<LoanTransaction> 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,58 +59,21 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
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;
}
}
}
if (amountAvailable.compareTo(BigDecimal.ZERO) < 0) {
outstandingAmount = outstandingAmount.add(amountAvailable.abs());
CollectionData nonOverDueInstallmentDelinquentData = calculateDelinquencyDataForNonOverdueInstallment(loan,
installment);
outstandingAmount = outstandingAmount.add(nonOverDueInstallmentDelinquentData.getDelinquentAmount());
if (!overdueSinceDateWasSet) {
overdueSinceDate = nonOverDueInstallmentDelinquentData.getDelinquentDate();
overdueSinceDateWasSet = true;
}
}
}
@@ -145,4 +106,178 @@ public class LoanDelinquencyDomainServiceImpl implements LoanDelinquencyDomainSe
return collectionData;
}
@Override
public LoanDelinquencyData getLoanDelinquencyData(final Loan loan) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
LocalDate overdueSinceDate = null;
CollectionData collectionData = CollectionData.template();
Map<Long, CollectionData> 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;
if (loan.getLoanProductRelatedDetail().getGraceOnArrearsAgeing() != null) {
graceDays = loan.getLoanProductRelatedDetail().getGraceOnArrearsAgeing();
}
log.debug("Loan id {} with overdue since date {} and outstanding amount {}", loan.getId(), overdueSinceDate, outstandingAmount);
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);
}
return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData);
}
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<LoanTransaction> 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<LoanTransaction> 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;
}
}
@@ -23,6 +23,7 @@ import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketMapping
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 class DelinquencyConfiguration {
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 class DelinquencyConfiguration {
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
@@ -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<Long, CollectionData> loanInstallmentsCollectionData;
}