From 28043ce43883976d57feb1c6ee126bb190c03f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soma=20S=C3=B6r=C3=B6s?= Date: Thu, 17 Jul 2025 14:21:51 +0200 Subject: [PATCH] FINERACT-2322: Extend loan collection data with next due payment amount field --- .../main/avro/loan/v1/CollectionDataV1.avsc | 8 + ...ssibleNextRepaymentCalculationService.java | 58 +++ .../DelinquencyReadPlatformServiceImpl.java | 7 + ...ssibleNextRepaymentCalculationService.java | 30 ++ ...tRepaymentCalculationServiceDiscovery.java | 35 ++ .../starter/DelinquencyConfiguration.java | 6 +- .../loanaccount/data/CollectionData.java | 3 +- .../portfolio/loanaccount/domain/Loan.java | 1 - ...dableProgressiveLoanScheduleGenerator.java | 5 + ...leNextRepaymentCalculationServiceImpl.java | 79 +++++ ...edPaymentScheduleTransactionProcessor.java | 135 ++++--- .../ProgressiveLoanModelRepository.java | 3 + ...nterestScheduleModelRepositoryWrapper.java | 2 + ...estScheduleModelRepositoryWrapperImpl.java | 10 + .../calc/data/RepaymentPeriod.java | 9 + ...ymentScheduleTransactionProcessorTest.java | 4 + .../api/LoansApiResourceSwagger.java | 2 + ...ntDelinquencyRangeEventSerializerTest.java | 4 +- ...tePlatformServiceRangeChangeEventTest.java | 32 +- .../BaseLoanIntegrationTest.java | 131 +++++++ ...anNextPaymentDueAmountIntegrationTest.java | 335 ++++++++++++++++++ 21 files changed, 807 insertions(+), 92 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/AbstractPossibleNextRepaymentCalculationService.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationService.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationServiceDiscovery.java create mode 100644 fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/ProgressivePossibleNextRepaymentCalculationServiceImpl.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanNextPaymentDueAmountIntegrationTest.java diff --git a/fineract-avro-schemas/src/main/avro/loan/v1/CollectionDataV1.avsc b/fineract-avro-schemas/src/main/avro/loan/v1/CollectionDataV1.avsc index 97387b864..68961e552 100644 --- a/fineract-avro-schemas/src/main/avro/loan/v1/CollectionDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/loan/v1/CollectionDataV1.avsc @@ -27,6 +27,14 @@ "string" ] }, + { + "default": null, + "name": "nextPaymentAmount", + "type": [ + "null", + "bigdecimal" + ] + }, { "default": null, "name": "delinquentDays", diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/AbstractPossibleNextRepaymentCalculationService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/AbstractPossibleNextRepaymentCalculationService.java new file mode 100644 index 000000000..d4db25a1e --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/AbstractPossibleNextRepaymentCalculationService.java @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; + +@RequiredArgsConstructor +public abstract class AbstractPossibleNextRepaymentCalculationService implements PossibleNextRepaymentCalculationService { + + @Override + public BigDecimal possibleNextRepaymentAmount(Loan loan, LocalDate nextPaymentDueDate) { + LoanRepaymentScheduleInstallment nextInstallment = loan.getRelatedRepaymentScheduleInstallment(nextPaymentDueDate); + if (nextInstallment == null || nextInstallment.isObligationsMet()) { + return BigDecimal.ZERO; + } + if (loan.isInterestRecalculationEnabled() + // if rest frequency type is same as repayment, then interest values should be on the repayment schedule + // correctly. + && !loan.getLoanInterestRecalculationDetails().getRestFrequencyType().isSameAsRepayment() + // if charge off, installments already shows correct values, no further calculation is required. + && !loan.isChargeOffOnDate(nextPaymentDueDate) + // all strategy works like same as repayment on installment due date. + && nextInstallment.getDueDate().isAfter(ThreadLocalContextUtil.getBusinessDate()) + // there is no overdue / overdue related to that installment is calculated. + && !nextInstallment.getFromDate().isEqual(ThreadLocalContextUtil.getBusinessDate()) + && MathUtil.isGreaterThanZero(loan.getDisbursedAmount())) { + // try to predict future outstanding balances with interest recalculation + return calculateInterestRecalculationFutureOutstandingValue(loan, nextPaymentDueDate, nextInstallment); + } + return nextInstallment.getTotalOutstanding(loan.getCurrency()).getAmount(); + } + + public abstract BigDecimal calculateInterestRecalculationFutureOutstandingValue(Loan loan, LocalDate nextPaymentDueDate, + LoanRepaymentScheduleInstallment nextInstallment); + +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java index 7c2fd3fcc..72f215ba6 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java @@ -81,6 +81,7 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo private final DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper; private final ConfigurationDomainService configurationDomainService; private final LoanTransactionRepository loanTransactionRepository; + private final PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationServiceDiscovery; @Override public List retrieveAllDelinquencyRanges() { @@ -152,6 +153,12 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList); collectionData.setAvailableDisbursementAmount(calculateAvailableDisbursementAmount(loan)); collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan)); + PossibleNextRepaymentCalculationService possibleNextRepaymentCalculationService = possibleNextRepaymentCalculationServiceDiscovery + .getService(loan); + if (possibleNextRepaymentCalculationService != null) { + collectionData.setNextPaymentAmount( + possibleNextRepaymentCalculationService.possibleNextRepaymentAmount(loan, collectionData.getNextPaymentDueDate())); + } final LoanTransaction lastPayment = loan.getLastPaymentTransaction(); if (lastPayment != null) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationService.java new file mode 100644 index 000000000..1bfc3cd22 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationService.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; + +public interface PossibleNextRepaymentCalculationService { + + boolean canAccept(Loan loan); + + BigDecimal possibleNextRepaymentAmount(Loan loan, LocalDate nextPaymentDueDate); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationServiceDiscovery.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationServiceDiscovery.java new file mode 100644 index 000000000..b4a803610 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/PossibleNextRepaymentCalculationServiceDiscovery.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.delinquency.service; + +import java.util.List; +import lombok.AllArgsConstructor; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.springframework.stereotype.Service; + +@AllArgsConstructor +@Service +public class PossibleNextRepaymentCalculationServiceDiscovery { + + private final List services; + + public PossibleNextRepaymentCalculationService getService(final Loan loan) { + return services.stream().filter(s -> s.canAccept(loan)).findAny().orElse(null); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java index 530ce477f..410f05e19 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/starter/DelinquencyConfiguration.java @@ -37,6 +37,7 @@ import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatfor import org.apache.fineract.portfolio.delinquency.service.DelinquencyWritePlatformServiceImpl; import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainService; import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl; +import org.apache.fineract.portfolio.delinquency.service.PossibleNextRepaymentCalculationServiceDiscovery; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyActionParseAndValidator; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyBucketParseAndValidator; import org.apache.fineract.portfolio.delinquency.validator.DelinquencyRangeParseAndValidator; @@ -62,11 +63,12 @@ public class DelinquencyConfiguration { LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag, LoanDelinquencyActionRepository loanDelinquencyActionRepository, DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper, ConfigurationDomainService configurationDomainService, - LoanTransactionRepository loanTransactionRepository) { + LoanTransactionRepository loanTransactionRepository, + PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationService) { return new DelinquencyReadPlatformServiceImpl(repositoryRange, repositoryBucket, repositoryLoanDelinquencyTagHistory, mapperRange, mapperBucket, mapperLoanDelinquencyTagHistory, loanRepository, loanDelinquencyDomainService, repositoryLoanInstallmentDelinquencyTag, loanDelinquencyActionRepository, delinquencyEffectivePauseHelper, - configurationDomainService, loanTransactionRepository); + configurationDomainService, loanTransactionRepository, possibleNextRepaymentCalculationService); } @Bean diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java index a89cd3e61..aa7c53e70 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/CollectionData.java @@ -31,6 +31,7 @@ public final class CollectionData { private BigDecimal availableDisbursementAmount; private Long pastDueDays; private LocalDate nextPaymentDueDate; + private BigDecimal nextPaymentAmount; private Long delinquentDays; private LocalDate delinquentDate; private BigDecimal delinquentAmount; @@ -50,7 +51,7 @@ public final class CollectionData { public static CollectionData template() { final BigDecimal zero = BigDecimal.ZERO; - return new CollectionData(zero, 0L, null, 0L, null, zero, null, zero, null, zero, null, null, zero, zero, zero, zero); + return new CollectionData(zero, 0L, null, zero, 0L, null, zero, null, zero, null, zero, null, null, zero, zero, zero, zero); } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index b878cbf4b..696b6c07b 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -1403,7 +1403,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { * @return a schedule installment is related to the provided date **/ public LoanRepaymentScheduleInstallment getRelatedRepaymentScheduleInstallment(LocalDate date) { - // TODO first installment should be fromInclusive return getRepaymentScheduleInstallment(e -> DateUtils.isDateInRangeFromExclusiveToInclusive(date, e.getFromDate(), e.getDueDate())); } diff --git a/fineract-progressive-loan-embeddable-schedule-generator/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGenerator.java b/fineract-progressive-loan-embeddable-schedule-generator/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGenerator.java index 7d8f6ddc8..90622ab2c 100644 --- a/fineract-progressive-loan-embeddable-schedule-generator/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGenerator.java +++ b/fineract-progressive-loan-embeddable-schedule-generator/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGenerator.java @@ -55,6 +55,11 @@ public class EmbeddableProgressiveLoanScheduleGenerator { return Optional.empty(); } + @Override + public Optional findOneByLoan(Loan loan) { + return Optional.empty(); + } + @Override public Optional extractModel(Optional progressiveLoanModel) { return Optional.empty(); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/ProgressivePossibleNextRepaymentCalculationServiceImpl.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/ProgressivePossibleNextRepaymentCalculationServiceImpl.java new file mode 100644 index 000000000..cae96576b --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/ProgressivePossibleNextRepaymentCalculationServiceImpl.java @@ -0,0 +1,79 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.domain.ProgressiveLoanModel; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.ProgressiveTransactionCtx; +import org.apache.fineract.portfolio.loanaccount.service.InterestScheduleModelRepositoryWrapper; +import org.apache.fineract.portfolio.loanproduct.calc.data.ProgressiveLoanInterestScheduleModel; +import org.apache.fineract.portfolio.loanproduct.calc.data.RepaymentPeriod; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProgressivePossibleNextRepaymentCalculationServiceImpl extends AbstractPossibleNextRepaymentCalculationService { + + private final InterestScheduleModelRepositoryWrapper interestScheduleModelRepository; + private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor; + + @Override + public BigDecimal calculateInterestRecalculationFutureOutstandingValue(Loan loan, LocalDate nextPaymentDueDate, + LoanRepaymentScheduleInstallment nextInstallment) { + MonetaryCurrency currency = loan.getCurrency(); + Optional progressiveLoanModel = interestScheduleModelRepository.findOneByLoan(loan); + Optional optionalScheduleModel = interestScheduleModelRepository + .extractModel(progressiveLoanModel); + ProgressiveLoanInterestScheduleModel scheduleModel = optionalScheduleModel.orElseGet( + () -> advancedPaymentScheduleTransactionProcessor.calculateInterestScheduleModel(loan.getId(), nextPaymentDueDate)); + if (scheduleModel == null) { + return BigDecimal.ZERO; + } + List repaymentScheduleInstallments = loan.getRepaymentScheduleInstallments(); + ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(loan.getCurrency(), repaymentScheduleInstallments, Set.of(), + new MoneyHolder(loan.getTotalOverpaidAsMoney()), new ChangedTransactionDetail(), scheduleModel); + ctx.setChargedOff(loan.isChargedOff()); + ctx.setContractTerminated(loan.isContractTermination()); + advancedPaymentScheduleTransactionProcessor.recalculateInterestForDate(nextPaymentDueDate, ctx, false); + RepaymentPeriod repaymentPeriod = scheduleModel.findRepaymentPeriodByDueDate(nextPaymentDueDate) + .orElseGet(scheduleModel::getLastRepaymentPeriod); + + return repaymentPeriod.getOutstandingPrincipal().add(repaymentPeriod.getOutstandingInterest()) + .add(nextInstallment.getFeeChargesOutstanding(currency)).add(nextInstallment.getPenaltyChargesOutstanding(currency)) + .getAmount(); + } + + @Override + public boolean canAccept(Loan loan) { + return loan.isProgressiveSchedule(); + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 88011da22..3aaef7ac5 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -1498,104 +1498,97 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep super.handleWriteOff(transaction, ctx.getCurrency(), ctx.getInstallments()); } - private List findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(LocalDate targetDate, - ProgressiveTransactionCtx transactionCtx) { - return transactionCtx.getInstallments().stream() // - .filter(installment -> !installment.isDownPayment() && !installment.isAdditional()) - .filter(installment -> installment.isOverdueOn(targetDate)) - .sorted(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).toList(); + private List findPossiblyOverdueRepaymentPeriods(LocalDate targetDate, ProgressiveLoanInterestScheduleModel model) { + return model.repaymentPeriods().stream() // + .filter(repaymentPeriod -> DateUtils.isAfter(targetDate, repaymentPeriod.getDueDate())).toList(); + } + + public boolean rework(LocalDate targetDate, ProgressiveTransactionCtx ctx) { + boolean hasChange = false; + ProgressiveLoanInterestScheduleModel model = ctx.getModel(); + List overdueInstallmentsSortedByInstallmentNumber = findPossiblyOverdueRepaymentPeriods(targetDate, model); + if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) { + RepaymentPeriod lastPeriod = model.getLastRepaymentPeriod(); + RepaymentPeriod currentPeriod = model.findRepaymentPeriod(targetDate).orElse(lastPeriod); + MonetaryCurrency currency = model.zero().getCurrency(); + Money overDuePrincipal = Money.zero(currency); + Money aggregatedOverDuePrincipal = Money.zero(currency); + for (RepaymentPeriod processingPeriod : overdueInstallmentsSortedByInstallmentNumber) { + // add and subtract outstanding principal + if (!overDuePrincipal.isZero()) { + boolean currentChanges = adjustOverduePrincipal(targetDate, processingPeriod, overDuePrincipal, + aggregatedOverDuePrincipal, ctx); + + hasChange = hasChange || currentChanges; + } + + overDuePrincipal = processingPeriod.getOutstandingPrincipal(); + aggregatedOverDuePrincipal = aggregatedOverDuePrincipal.add(overDuePrincipal); + } + + if (!currentPeriod.equals(lastPeriod) || !targetDate.isAfter(lastPeriod.getDueDate())) { + boolean currentChanges = adjustOverduePrincipal(targetDate, currentPeriod, overDuePrincipal, aggregatedOverDuePrincipal, + ctx); + hasChange = hasChange || currentChanges; + + } + if (aggregatedOverDuePrincipal.isGreaterThanZero() + && (model.lastOverdueBalanceChange() == null || model.lastOverdueBalanceChange().isBefore(targetDate))) { + model.lastOverdueBalanceChange(targetDate); + } + } + + return hasChange; } public void recalculateInterestForDate(LocalDate targetDate, ProgressiveTransactionCtx ctx) { + recalculateInterestForDate(targetDate, ctx, true); + } + + public void recalculateInterestForDate(LocalDate targetDate, ProgressiveTransactionCtx ctx, boolean updateInstallments) { if (ctx.getInstallments() != null && !ctx.getInstallments().isEmpty()) { Loan loan = ctx.getInstallments().getFirst().getLoan(); if (loan.isInterestBearingAndInterestRecalculationEnabled() && !loan.isNpa() && !ctx.isChargedOff() - && !ctx.isContractTerminated()) { + && !ctx.isContractTerminated() && !loan.getLoanInterestRecalculationDetails().disallowInterestCalculationOnPastDue()) { - List overdueInstallmentsSortedByInstallmentNumber = findOverdueInstallmentsBeforeDateSortedByInstallmentNumber( - targetDate, ctx); - if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) { - List normalInstallments = ctx.getInstallments().stream() // - .filter(installment -> !installment.isAdditional() && !installment.isDownPayment()).toList(); - - Optional currentInstallmentOptional = normalInstallments.stream().filter( - installment -> installment.getFromDate().isBefore(targetDate) && !installment.getDueDate().isBefore(targetDate)) - .findAny(); - - // get DUE installment or last installment - LoanRepaymentScheduleInstallment lastInstallment = normalInstallments.stream() - .max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).get(); - LoanRepaymentScheduleInstallment currentInstallment = currentInstallmentOptional.orElse(lastInstallment); - - Money overDuePrincipal = Money.zero(ctx.getCurrency()); - Money aggregatedOverDuePrincipal = Money.zero(ctx.getCurrency()); - for (LoanRepaymentScheduleInstallment processingInstallment : overdueInstallmentsSortedByInstallmentNumber) { - // add and subtract outstanding principal - if (!overDuePrincipal.isZero()) { - adjustOverduePrincipalForInstallment(targetDate, processingInstallment, overDuePrincipal, - aggregatedOverDuePrincipal, ctx); - } - - overDuePrincipal = processingInstallment.getPrincipalOutstanding(ctx.getCurrency()); - aggregatedOverDuePrincipal = aggregatedOverDuePrincipal.add(overDuePrincipal); - } - - boolean adjustNeeded = !currentInstallment.equals(lastInstallment) || !lastInstallment.isOverdueOn(targetDate); - if (adjustNeeded) { - adjustOverduePrincipalForInstallment(targetDate, currentInstallment, overDuePrincipal, aggregatedOverDuePrincipal, - ctx); - } - if (aggregatedOverDuePrincipal.isGreaterThanZero() && (ctx.getModel().lastOverdueBalanceChange() == null - || ctx.getModel().lastOverdueBalanceChange().isBefore(targetDate))) { - ctx.getModel().lastOverdueBalanceChange(targetDate); - } + boolean modelHasUpdates = rework(targetDate, ctx); + if (modelHasUpdates && updateInstallments) { + updateInstallmentsPrincipalAndInterestByModel(ctx); } } } } - private void adjustOverduePrincipalForInstallment(LocalDate currentDate, LoanRepaymentScheduleInstallment currentInstallment, - Money overduePrincipal, Money aggregatedOverDuePrincipal, ProgressiveTransactionCtx ctx) { - if (currentInstallment.getLoan().getLoanInterestRecalculationDetails().disallowInterestCalculationOnPastDue()) { - return; - } + private boolean adjustOverduePrincipal(LocalDate currentDate, RepaymentPeriod currentInstallment, Money overduePrincipal, + Money aggregatedOverDuePrincipal, ProgressiveTransactionCtx ctx) { LocalDate fromDate = currentInstallment.getFromDate(); LocalDate toDate = currentInstallment.getDueDate(); - boolean hasUpdate = false; + ProgressiveLoanInterestScheduleModel model = ctx.getModel(); + boolean prepayAttempt = ctx.isPrepayAttempt(); + LoanInterestRecalculationDetails loanInterestRecalculationDetails = ctx.getInstallments().getFirst().getLoan() + .getLoanInterestRecalculationDetails(); - if (!currentDate.equals(ctx.getModel().lastOverdueBalanceChange())) { - // if we have same date for fromDate & last overdue balance change then it means we have the up-to-date - // model. - if (ctx.getModel().lastOverdueBalanceChange() == null - || currentInstallment.getFromDate().isAfter(ctx.getModel().lastOverdueBalanceChange())) { - // first overdue hit for installment. setting overdue balance correction from instalment from date. - emiCalculator.addBalanceCorrection(ctx.getModel(), fromDate, overduePrincipal); + if (!currentDate.equals(model.lastOverdueBalanceChange())) { + if (model.lastOverdueBalanceChange() == null || currentInstallment.getFromDate().isAfter(model.lastOverdueBalanceChange())) { + emiCalculator.addBalanceCorrection(model, fromDate, overduePrincipal); } else { - // not the first balance correction on installment period, then setting overdue balance correction from - // last balance change's current date. previous interest period already has the correct balance - // correction. - emiCalculator.addBalanceCorrection(ctx.getModel(), ctx.getModel().lastOverdueBalanceChange(), overduePrincipal); + emiCalculator.addBalanceCorrection(model, model.lastOverdueBalanceChange(), overduePrincipal); } - hasUpdate = true; - if (currentDate.isAfter(fromDate) && !currentDate.isAfter(toDate)) { LocalDate lastOverdueBalanceChange; - if (shouldRecalculateTillInstallmentDueDate(currentInstallment.getLoan().getLoanInterestRecalculationDetails(), - ctx.isPrepayAttempt())) { + if (shouldRecalculateTillInstallmentDueDate(loanInterestRecalculationDetails, prepayAttempt)) { lastOverdueBalanceChange = toDate; } else { lastOverdueBalanceChange = currentDate; } - emiCalculator.addBalanceCorrection(ctx.getModel(), lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated()); - ctx.getModel().lastOverdueBalanceChange(lastOverdueBalanceChange); + emiCalculator.addBalanceCorrection(model, lastOverdueBalanceChange, aggregatedOverDuePrincipal.negated()); + model.lastOverdueBalanceChange(lastOverdueBalanceChange); } + return true; } - - if (hasUpdate) { - updateInstallmentsPrincipalAndInterestByModel(ctx); - } + return false; } private void updateInstallmentsPrincipalAndInterestByModel(ProgressiveTransactionCtx ctx) { diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/ProgressiveLoanModelRepository.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/ProgressiveLoanModelRepository.java index b49beb9d2..be6af1aac 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/ProgressiveLoanModelRepository.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/ProgressiveLoanModelRepository.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.loanaccount.repository; import java.util.Optional; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.ProgressiveLoanModel; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -27,4 +28,6 @@ public interface ProgressiveLoanModelRepository extends JpaSpecificationExecutor, JpaRepository { Optional findOneByLoanId(Long loanId); + + Optional findOneByLoan(Loan loan); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapper.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapper.java index e6e8c6790..243f8702a 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapper.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapper.java @@ -29,6 +29,8 @@ public interface InterestScheduleModelRepositoryWrapper { Optional findOneByLoanId(Long loanId); + Optional findOneByLoan(Loan loan); + Optional extractModel(Optional progressiveLoanModel); ProgressiveLoanInterestScheduleModel writeInterestScheduleModel(Loan loan, ProgressiveLoanInterestScheduleModel model); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapperImpl.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapperImpl.java index 2aabbaa89..d18ebfc92 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapperImpl.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/InterestScheduleModelRepositoryWrapperImpl.java @@ -23,6 +23,7 @@ import jakarta.transaction.Transactional; import java.time.LocalDate; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.persistence.FlushModeHandler; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -78,6 +79,15 @@ public class InterestScheduleModelRepositoryWrapperImpl implements InterestSched return progressiveLoanModel[0]; } + @Override + public Optional findOneByLoan(Loan loan) { + AtomicReference> progressiveLoanModelRef = new AtomicReference<>(); + flushModeHandler.withFlushMode(FlushModeType.COMMIT, () -> { + progressiveLoanModelRef.set(loanModelRepository.findOneByLoan(loan)); + }); + return progressiveLoanModelRef.get(); + } + @Override public Optional extractModel(Optional progressiveLoanModel) { return progressiveLoanModel.map(ProgressiveLoanModel::getJsonModel) // diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java index 623b4b6e7..6e6f0e497 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/data/RepaymentPeriod.java @@ -381,6 +381,15 @@ public final class RepaymentPeriod { return previous == null; } + /** + * Gives back getDueInterest minus paid interest + * + * @return + */ + public Money getOutstandingInterest() { + return MathUtil.negativeToZero(getDueInterest().minus(getPaidInterest()), mc); + } + public Money getOutstandingPrincipal() { return MathUtil.negativeToZero(getDuePrincipal().minus(getPaidPrincipal()), mc); } diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java index 534fbd4da..f64aea715 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java @@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; import org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule; +import org.apache.fineract.portfolio.loanaccount.domain.LoanInterestRecalculationDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; @@ -467,6 +468,9 @@ class AdvancedPaymentScheduleTransactionProcessorTest { when(loanTransaction.getLoan()).thenReturn(loan); when(loan.getCurrency()).thenReturn(currency); when(loan.getPaymentAllocationRules()).thenReturn(List.of(loanPaymentAllocationRule)); + LoanInterestRecalculationDetails loanInterestRecalculationDetails = mock(LoanInterestRecalculationDetails.class); + when(loanInterestRecalculationDetails.disallowInterestCalculationOnPastDue()).thenReturn(false); + when(loan.getLoanInterestRecalculationDetails()).thenReturn(loanInterestRecalculationDetails); when(loanPaymentAllocationRule.getTransactionType()).thenReturn(PaymentAllocationTransactionType.DEFAULT); when(loanPaymentAllocationRule.getAllocationTypes()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index b609596bc..917e065b0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -1022,6 +1022,8 @@ final class LoansApiResourceSwagger { public Integer pastDueDays; @Schema(example = "[2022, 07, 01]") public LocalDate nextPaymentDueDate; + @Schema(example = "123.23") + public BigDecimal nextPaymentAmount; @Schema(example = "4") public Integer delinquentDays; @Schema(example = "[2022, 07, 01]") diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java index 562ae63b9..af2da0746 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/serialization/serializer/loan/LoanAccountDelinquencyRangeEventSerializerTest.java @@ -82,6 +82,7 @@ import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformService; import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformServiceImpl; import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainService; +import org.apache.fineract.portfolio.delinquency.service.PossibleNextRepaymentCalculationServiceDiscovery; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; import org.apache.fineract.portfolio.loanaccount.data.LoanAccountData; import org.apache.fineract.portfolio.loanaccount.domain.Loan; @@ -364,7 +365,8 @@ public class LoanAccountDelinquencyRangeEventSerializerTest { DelinquencyReadPlatformService delinquencyReadPlatformService = new DelinquencyReadPlatformServiceImpl(repositoryRange, repositoryBucket, repositoryLoanDelinquencyTagHistory, mapperRange, mapperBucket, mapperLoanDelinquencyTagHistory, loanRepository, loanDelinquencyDomainService, repositoryLoanInstallmentDelinquencyTag, loanDelinquencyActionRepository, - delinquencyEffectivePauseHelper, configurationDomainService, Mockito.mock(LoanTransactionRepository.class)); + delinquencyEffectivePauseHelper, configurationDomainService, Mockito.mock(LoanTransactionRepository.class), + Mockito.mock(PossibleNextRepaymentCalculationServiceDiscovery.class)); LoanProduct loanProduct = Mockito.mock(LoanProduct.class); when(loanProduct.isMultiDisburseLoan()).thenReturn(false); 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 39ee62f06..e388a847a 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 @@ -186,8 +186,8 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); final BigDecimal zero = BigDecimal.ZERO; - CollectionData collectionData = new CollectionData(zero, 2L, null, 2L, overDueSinceDate, zero, null, null, null, null, null, null, - zero, zero, zero, zero); + CollectionData collectionData = new CollectionData(zero, 2L, null, zero, 2L, overDueSinceDate, zero, null, null, null, null, null, + null, zero, zero, zero, zero); Map installmentsCollection = new HashMap<>(); @@ -240,10 +240,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LocalDate overDueSinceDate = DateUtils.getBusinessLocalDate().minusDays(2); LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); - CollectionData collectionData = new CollectionData(zeroAmount, 2L, null, 2L, overDueSinceDate, zeroAmount, null, null, null, null, - null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); + CollectionData collectionData = new CollectionData(zeroAmount, 2L, null, zeroAmount, 2L, overDueSinceDate, zeroAmount, null, null, + null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); - CollectionData installmentCollectionData = new CollectionData(zeroAmount, 2L, null, 2L, overDueSinceDate, + CollectionData installmentCollectionData = new CollectionData(zeroAmount, 2L, null, zeroAmount, 2L, overDueSinceDate, installmentPrincipalAmount, null, null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); Map installmentsCollection = new HashMap<>(); @@ -369,10 +369,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); - CollectionData collectionData = new CollectionData(zeroAmount, 2L, null, 2L, overDueSinceDate, zeroAmount, null, null, null, null, - null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); + CollectionData collectionData = new CollectionData(zeroAmount, 2L, null, zeroAmount, 2L, overDueSinceDate, zeroAmount, null, null, + null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); - CollectionData installmentCollectionData = new CollectionData(zeroAmount, 2L, null, 2L, overDueSinceDate, + CollectionData installmentCollectionData = new CollectionData(zeroAmount, 2L, null, zeroAmount, 2L, overDueSinceDate, installmentPrincipalAmount, null, null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); Map installmentsCollection = new HashMap<>(); @@ -446,10 +446,10 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LocalDate overDueSinceDate = DateUtils.getBusinessLocalDate().minusDays(29); LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); - CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 29L, null, 29L, overDueSinceDate, zeroAmount, null, null, null, - null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); + CollectionData collectionData = new CollectionData(BigDecimal.ZERO, 29L, null, zeroAmount, 29L, overDueSinceDate, zeroAmount, null, + null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); - CollectionData installmentCollectionData = new CollectionData(zeroAmount, 29L, null, 29L, overDueSinceDate, + CollectionData installmentCollectionData = new CollectionData(zeroAmount, 29L, null, zeroAmount, 29L, overDueSinceDate, installmentPrincipalAmount, null, null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); Map installmentsCollection = new HashMap<>(); @@ -534,14 +534,14 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest { LocalDate overDueSinceDate = DateUtils.getBusinessLocalDate().minusDays(29); LoanScheduleDelinquencyData loanScheduleDelinquencyData = new LoanScheduleDelinquencyData(1L, overDueSinceDate, 1L, loanForProcessing); - CollectionData collectionData = new CollectionData(zeroAmount, 29L, null, 29L, overDueSinceDate, zeroAmount, null, null, null, null, - null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); + CollectionData collectionData = new CollectionData(zeroAmount, 29L, null, zeroAmount, 29L, overDueSinceDate, zeroAmount, null, null, + null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); - CollectionData installmentCollectionData_1 = new CollectionData(zeroAmount, 29L, null, 29L, overDueSinceDate, + CollectionData installmentCollectionData_1 = new CollectionData(zeroAmount, 29L, null, zeroAmount, 29L, overDueSinceDate, installmentPrincipalAmount, null, null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); - CollectionData installmentCollectionData_2 = new CollectionData(zeroAmount, 0L, null, 0L, null, installmentPrincipalAmount, null, - null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); + CollectionData installmentCollectionData_2 = new CollectionData(zeroAmount, 0L, null, zeroAmount, 0L, null, + installmentPrincipalAmount, null, null, null, null, null, null, zeroAmount, zeroAmount, zeroAmount, zeroAmount); Map installmentsCollection = new HashMap<>(); installmentsCollection.put(1L, installmentCollectionData_1); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index 2f8703e70..5a92cf5fc 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -20,6 +20,7 @@ package org.apache.fineract.integrationtests; import static java.lang.System.lineSeparator; import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; +import static org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY; import static org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder.DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -318,6 +319,107 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { return createOnePeriod30DaysPeriodicAccrualProduct((double) 0); } + protected PostLoanProductsRequest create4ICumulative() { + final Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec); + Assertions.assertNotNull(delinquencyBucketId); + + return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("4I_PROGRESSIVE_", 6))// + .shortName(Utils.uniqueRandomStringGenerator("", 4))// + .description("4 installment product - progressive")// + .includeInBorrowerCycle(false)// + .useBorrowerCycle(false)// + .currencyCode("EUR")// + .digitsAfterDecimal(2)// + .principal(1000.0)// + .minPrincipal(100.0)// + .maxPrincipal(10000.0)// + .numberOfRepayments(4)// + .repaymentEvery(1)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L)// + .interestRatePerPeriod(10D)// + .minInterestRatePerPeriod(0D)// + .maxInterestRatePerPeriod(120D)// + .interestRateFrequencyType(InterestRateFrequencyType.YEARS)// + .isLinkedToFloatingInterestRates(false)// + .isLinkedToFloatingInterestRates(false)// + .allowVariableInstallments(false)// + .amortizationType(AmortizationType.EQUAL_INSTALLMENTS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .allowPartialPeriodInterestCalcualtion(false)// + .creditAllocation(List.of())// + .overdueDaysForNPA(179)// + .daysInMonthType(30)// + .daysInYearType(360)// + .isInterestRecalculationEnabled(true)// + .interestRecalculationCompoundingMethod(0)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.RESCHEDULE_NEXT_REPAYMENTS)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .recalculationRestFrequencyInterval(1)// + .isArrearsBasedOnOriginalSchedule(false)// + .isCompoundingToBePostedAsTransaction(false)// + .preClosureInterestCalculationStrategy(1)// + .allowCompoundingOnEod(false)// + .canDefineInstallmentAmount(true)// + .repaymentStartDateType(1)// + .charges(List.of())// + .principalVariationsForBorrowerCycle(List.of())// + .interestRateVariationsForBorrowerCycle(List.of())// + .numberOfRepaymentVariationsForBorrowerCycle(List.of())// + .accountingRule(3)// + .canUseForTopup(false)// + .fundSourceAccountId(fundSource.getAccountID().longValue())// + .loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())// + .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())// + .interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())// + .incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())// + .incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue())// + .incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())// + .writeOffAccountId(writtenOffAccount.getAccountID().longValue())// + .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())// + .receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue())// + .receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue())// + .receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue())// + .goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditFeesAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromGoodwillCreditPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffFeesAccountId(feeChargeOffAccount.getAccountID().longValue())// + .incomeFromChargeOffPenaltyAccountId(penaltyChargeOffAccount.getAccountID().longValue())// + .chargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue())// + .chargeOffFraudExpenseAccountId(chargeOffFraudExpenseAccount.getAccountID().longValue())// + .dateFormat(DATETIME_PATTERN)// + .locale("en")// + .enableAccrualActivityPosting(false)// + .multiDisburseLoan(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .disallowExpectedDisbursements(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType("percentage")// + .overAppliedNumber(50)// + .principalThresholdForLastInstallment(50)// + .holdGuaranteeFunds(false)// + .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)// + .allowAttributeOverrides(new AllowAttributeOverrides()// + .amortizationType(true)// + .interestType(true)// + .transactionProcessingStrategyCode(true)// + .interestCalculationPeriodType(true)// + .inArrearsTolerance(true)// + .repaymentEvery(true)// + .graceOnPrincipalAndInterestPayment(true)// + .graceOnArrearsAgeing(true)// + ).isEqualAmortization(false)// + .delinquencyBucketId(delinquencyBucketId.longValue())// + .enableDownPayment(false)// + .enableInstallmentLevelDelinquency(false)// + .transactionProcessingStrategyCode( + LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY)// + .loanScheduleType(LoanScheduleType.CUMULATIVE.toString());// + } + protected PostLoanProductsRequest create4IProgressive() { final Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec); Assertions.assertNotNull(delinquencyBucketId); @@ -1076,6 +1178,23 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { return postLoansRequest; } + protected PostLoansRequest applyCumulativeLoanRequest(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, + Double interestRate, int numberOfRepayments, Consumer customizer) { + + PostLoansRequest postLoansRequest = new PostLoansRequest().clientId(clientId) + .transactionProcessingStrategyCode(DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY) + .productId(loanProductId).expectedDisbursementDate(loanDisbursementDate).dateFormat(DATETIME_PATTERN).locale("en") + .submittedOnDate(loanDisbursementDate).amortizationType(1).interestRatePerPeriod(BigDecimal.valueOf(interestRate)) + .numberOfRepayments(numberOfRepayments).principal(BigDecimal.valueOf(amount)).loanTermFrequency(numberOfRepayments) + .repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS) + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS).interestType(InterestType.DECLINING_BALANCE) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).loanType("individual"); + if (customizer != null) { + customizer.accept(postLoansRequest); + } + return postLoansRequest; + } + protected PostLoansRequest applyLP2ProgressiveLoanRequest(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, Double interestRate, int numberOfRepayments, Consumer customizer) { @@ -1119,6 +1238,17 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { return approvedLoanResult.getLoanId(); } + protected Long applyAndApproveCumulativeLoan(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, + Double interestRate, int numberOfRepayments, Consumer customizer) { + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applyCumulativeLoanRequest(clientId, loanProductId, + loanDisbursementDate, amount, interestRate, numberOfRepayments, customizer)); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, loanDisbursementDate)); + + return approvedLoanResult.getLoanId(); + } + protected Long applyAndApproveProgressiveLoan(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, Double interestRate, int numberOfRepayments, Consumer customizer) { PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applyLP2ProgressiveLoanRequest(clientId, loanProductId, @@ -1548,6 +1678,7 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { public static class RescheduleStrategyMethod { + public static final Integer RESCHEDULE_NEXT_REPAYMENTS = 1; public static final Integer REDUCE_EMI_AMOUNT = 3; public static final Integer ADJUST_LAST_UNPAID_PERIOD = 4; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanNextPaymentDueAmountIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanNextPaymentDueAmountIntegrationTest.java new file mode 100644 index 000000000..4057a3fc0 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanNextPaymentDueAmountIntegrationTest.java @@ -0,0 +1,335 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.avro.loan.v1.LoanAccountDataV1; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.infrastructure.event.external.data.ExternalEventResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.externalevents.ExternalEventHelper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +@Slf4j +public class LoanNextPaymentDueAmountIntegrationTest extends BaseLoanIntegrationTest { + + ObjectMapper objectMapper = new ObjectMapper(); + private static final String LOAN_ACCOUNT_DATA_V_1 = "org.apache.fineract.avro.loan.v1.LoanAccountDataV1"; + + @Test + void test_progressive_interest_noRecalculation() { + externalEventHelper.enableBusinessEvent("LoanApprovedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanBalanceChangedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanDisbursalBusinessEvent"); + AtomicReference loanIdRef = new AtomicReference<>(); + runAt("15 January 2023", () -> { + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + Long loanProductId = loanProductHelper.createLoanProduct(create4IProgressive().isInterestRecalculationEnabled(false)) + .getResourceId(); + + Long loanId = applyAndApproveProgressiveLoan(clientId, loanProductId, "01 January 2023", 100.0, 9.9, 4, null); + loanIdRef.set(loanId); + + deleteAllExternalEvents(); + loanTransactionHelper.disburseLoan(loanId, "01 January 2023", 100.0); + + verifyRepaymentSchedule(loanId, // + installment(100.000000, null, "01 January 2023"), // + installment(24.700000, 0.820000, 25.520000, false, "01 February 2023"), // + installment(24.900000, 0.620000, 25.520000, false, "01 March 2023"), // + installment(25.100000, 0.420000, 25.520000, false, "01 April 2023"), // + installment(25.300000, 0.210000, 25.510000, false, "01 May 2023") // + ); + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-02-01", 25.52d); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("31 January 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("1 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("2 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + } + + @Test + void test_progressive_interest_noRecalculation_prepay() { + externalEventHelper.enableBusinessEvent("LoanApprovedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanBalanceChangedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanDisbursalBusinessEvent"); + AtomicReference loanIdRef = new AtomicReference<>(); + runAt("15 January 2023", () -> { + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + Long loanProductId = loanProductHelper.createLoanProduct(create4IProgressive().isInterestRecalculationEnabled(false)) + .getResourceId(); + + Long loanId = applyAndApproveProgressiveLoan(clientId, loanProductId, "01 January 2023", 100.0, 9.9, 4, null); + loanIdRef.set(loanId); + + deleteAllExternalEvents(); + loanTransactionHelper.disburseLoan(loanId, "01 January 2023", 100.0); + + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-02-01", 25.52d); + + deleteAllExternalEvents(); + addRepaymentForLoan(loanId, 25.52d, "15 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(100.000000, null, "01 January 2023"), // + installment(24.700000, 0.820000, 0.0, true, "01 February 2023"), // + installment(24.900000, 0.620000, 25.520000, false, "01 March 2023"), // + installment(25.100000, 0.420000, 25.520000, false, "01 April 2023"), // + installment(25.300000, 0.210000, 25.510000, false, "01 May 2023") // + ); + + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-03-01", 25.52d); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("31 January 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("1 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("2 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("2 March 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + deleteAllExternalEvents(); + addRepaymentForLoan(loanId, 25.52d, "02 March 2023"); + + verifyRepaymentSchedule(loanId, // + installment(100.000000, null, "01 January 2023"), // + installment(24.700000, 0.820000, 0.0, true, "01 February 2023"), // + installment(24.900000, 0.620000, 0.0, true, "01 March 2023"), // + installment(25.100000, 0.420000, 25.520000, false, "01 April 2023"), // + installment(25.300000, 0.210000, 25.510000, false, "01 May 2023") // + ); + + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-04-01", 25.52d); + + }); + } + + @Test + void test_progressive_interest_recalculation_sameAsRepaymentPeriod() { + externalEventHelper.enableBusinessEvent("LoanApprovedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanBalanceChangedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanDisbursalBusinessEvent"); + AtomicReference loanIdRef = new AtomicReference<>(); + runAt("15 February 2023", () -> { + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + Long loanProductId = loanProductHelper + .createLoanProduct(create4IProgressive().isInterestRecalculationEnabled(true).recalculationRestFrequencyInterval(1) + .recalculationRestFrequencyType(RecalculationRestFrequencyType.SAME_AS_REPAYMENT_PERIOD)) + .getResourceId(); + + Long loanId = applyAndApproveProgressiveLoan(clientId, loanProductId, "01 January 2023", 100.0, 9.9, 4, null); + loanIdRef.set(loanId); + + deleteAllExternalEvents(); + loanTransactionHelper.disburseLoan(loanId, "01 January 2023", 100.0); + + verifyRepaymentSchedule(loanId, // + installment(100.000000, null, "01 January 2023"), // + installment(24.700000, 0.820000, 25.520000, false, "01 February 2023"), // + installment(24.800000, 0.720000, 25.520000, false, "01 March 2023"), // + installment(25.100000, 0.420000, 25.520000, false, "01 April 2023"), // + installment(25.400000, 0.210000, 25.610000, false, "01 May 2023") // + ); + + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-02-01", 25.52d); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("31 January 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("1 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("2 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 2, 1), BigDecimal.valueOf(25.52d)); + }); + } + + @Test + void test_progressive_interest_recalculation_daily() { + externalEventHelper.enableBusinessEvent("LoanApprovedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanBalanceChangedBusinessEvent"); + externalEventHelper.enableBusinessEvent("LoanDisbursalBusinessEvent"); + AtomicReference loanIdRef = new AtomicReference<>(); + runAt("15 February 2023", () -> { + + Long clientId = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + Long loanProductId = loanProductHelper.createLoanProduct(create4IProgressive().isInterestRecalculationEnabled(true) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).recalculationRestFrequencyInterval(1) + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)).getResourceId(); + + Long loanId = applyAndApproveProgressiveLoan(clientId, loanProductId, "01 January 2023", 100.0, 9.9, 4, null); + + loanIdRef.set(loanId); + + deleteAllExternalEvents(); + loanTransactionHelper.disburseLoan(loanId, "01 January 2023", 100.0); + + verifyRepaymentSchedule(loanId, // + installment(100.000000, null, "01 January 2023"), // + installment(24.700000, 0.820000, 25.520000, false, "01 February 2023"), // + installment(24.800000, 0.720000, 25.520000, false, "01 March 2023"), // + installment(25.100000, 0.420000, 25.520000, false, "01 April 2023"), // + installment(25.400000, 0.210000, 25.610000, false, "01 May 2023") // + ); + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-02-01", 25.52d); + + deleteAllExternalEvents(); + addRepaymentForLoan(loanId, 25.52d, "15 January 2023"); + + verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount("2023-03-01", 25.52d); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + + }); + runAt("31 January 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("1 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + runAt("2 February 2023", () -> { + Long loanId = loanIdRef.get(); + inlineLoanCOBHelper.executeInlineCOB(loanId); + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + verifyNextPayment(loanDetails, LocalDate.of(2023, 3, 1), BigDecimal.valueOf(25.52d)); + }); + + } + + /** + * verifies that all the external events which has org.apache.fineract.avro.loan.v1.LoanAccountDataV1 types payload + * has the correct nextPaymentDueDate and nextPaymentDueAmount + * + * @param dueDate + * expected due date formatted yyyy-MM-dd format (ie 2023-12-31 ) + * @param amount + * expected amount + */ + private void verifyAllLoanAccountTypedExternalEventHasNextPaymentDueAmount(String dueDate, Double amount) { + List allExternalEvents = ExternalEventHelper.getAllExternalEvents(requestSpec, responseSpec); + allExternalEvents.stream().filter(e -> LOAN_ACCOUNT_DATA_V_1.equals(e.getSchema())) + .forEach(event -> verifyLoanEvent(event, data -> verifyNextPayment(data, dueDate, BigDecimal.valueOf(amount)))); + + } + + // utility + LoanAccountDataV1 convertLoan(ExternalEventResponse externalEventDTO) { + if (LOAN_ACCOUNT_DATA_V_1.equals(externalEventDTO.getSchema())) { + return objectMapper.convertValue(externalEventDTO.getPayLoad(), LoanAccountDataV1.class); + } else { + throw new RuntimeException("Unexpected schema: " + externalEventDTO.getSchema()); + } + } + + void verifyLoanEvent(ExternalEventResponse externalEventDTO, Consumer validator) { + validator.accept(convertLoan(externalEventDTO)); + } + + void verifyNextPayment(LoanAccountDataV1 loanAccountData, String nextPaymentDueDate, BigDecimal nextPaymentAmount) { + Assertions.assertNotNull(loanAccountData, "loanDetails should not be null"); + Assertions.assertNotNull(loanAccountData.getDelinquent(), "loanDetails.delinquent should not be null"); + String nextPaymentDueDateActual = loanAccountData.getDelinquent().getNextPaymentDueDate(); + BigDecimal nextPaymentAmountActual = loanAccountData.getDelinquent().getNextPaymentAmount(); + log.info("Verify ExternalEventResponse nextPaymentDueDate. Expected: {}, Actual: {}", nextPaymentDueDate, nextPaymentDueDateActual); + Assertions.assertEquals(nextPaymentDueDate, nextPaymentDueDateActual); + log.info("Verify ExternalEventResponse nextPaymentAmount. Expected: {}, Actual: {}", nextPaymentAmount, nextPaymentAmountActual); + Assertions.assertEquals(nextPaymentAmount, nextPaymentAmountActual); + } + + void verifyNextPayment(GetLoansLoanIdResponse loanDetails, LocalDate nextPaymentDueDate, BigDecimal nextPaymentAmount) { + Assertions.assertNotNull(loanDetails, "loanDetails should not be null"); + Assertions.assertNotNull(loanDetails.getDelinquent(), "loanDetails.delinquent should not be null"); + LocalDate nextPaymentDueDateActual = loanDetails.getDelinquent().getNextPaymentDueDate(); + BigDecimal nextPaymentAmountActual = loanDetails.getDelinquent().getNextPaymentAmount(); + log.info("Verify GetLoansLoanIdResponse nextPaymentDueDate. Expected: {}, Actual: {}", nextPaymentDueDate, + nextPaymentDueDateActual); + Assertions.assertEquals(nextPaymentDueDate, nextPaymentDueDateActual); + log.info("Verify GetLoansLoanIdResponse nextPaymentAmount. Expected: {}, Actual: {}", nextPaymentAmount, nextPaymentAmountActual); + Assertions.assertEquals(nextPaymentAmount, nextPaymentAmountActual); + } +}