diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanChargeOff.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanChargeOff.feature index 87596c48f9..546cdc6575 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanChargeOff.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanChargeOff.feature @@ -1578,3 +1578,36 @@ Feature: Charge-off | ASSET | 112601 | Loans Receivable | 650.0 | | | EXPENSE | 744037 | Credit Loss/Bad Debt-Fraud | | 650.0 | + @TestRailId:C3326 @AdvancedPaymentAllocation + Scenario: Verify the repayment schedule is updated before the Charge-off in case of interest recalculation = true + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_TILL_PRECLOSE_PMT_ALLOC_1 | 01 January 2024 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "1000" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "1000" EUR transaction amount + When Admin runs inline COB job for Loan + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 668.6 | 331.4 | 5.83 | 0.0 | 0.0 | 337.23 | 0.0 | 0.0 | 0.0 | 337.23 | + | 2 | 29 | 01 March 2024 | | 335.27 | 333.33 | 3.9 | 0.0 | 0.0 | 337.23 | 0.0 | 0.0 | 0.0 | 337.23 | + | 3 | 31 | 01 April 2024 | | 0.0 | 335.27 | 1.96 | 0.0 | 0.0 | 337.23 | 0.0 | 0.0 | 0.0 | 337.23 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000 | 11.69 | 0 | 0 | 1011.69 | 0 | 0 | 0 | 1011.69 | + When Admin sets the business date to "15 February 2024" + When Admin runs inline COB job for Loan + # Move the current date into the middle of the 2nd period, so 1st period is past due + When Admin sets the business date to "15 August 2024" + And Admin does charge-off the loan on "09 February 2024" + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 668.6 | 331.4 | 5.83 | 0.0 | 0.0 | 337.23 | 0.0 | 0.0 | 0.0 | 337.23 | + | 2 | 29 | 01 March 2024 | | 335.8 | 332.8 | 4.43 | 0.0 | 0.0 | 337.23 | 0.0 | 0.0 | 0.0 | 337.23 | + | 3 | 31 | 01 April 2024 | | 0.0 | 335.8 | 1.96 | 0.0 | 0.0 | 337.76 | 0.0 | 0.0 | 0.0 | 337.76 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000 | 12.22 | 0 | 0 | 1012.22 | 0 | 0 | 0 | 1012.22 | 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 ebc7d95528..cc8039f159 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 @@ -1187,7 +1187,7 @@ public void updateLoanSummaryAndStatus() { doPostLoanTransactionChecks(getLastUserTransactionDate(), loanLifecycleStateMachine); } - public boolean isInterestRecalculationEnabledForProduct() { + private boolean isInterestRecalculationEnabledForProduct() { return this.loanProduct.isInterestRecalculationEnabled(); } @@ -2647,6 +2647,10 @@ public boolean isInterestBearing() { return BigDecimal.ZERO.compareTo(getLoanRepaymentScheduleDetail().getAnnualNominalInterestRate()) < 0; } + public boolean isInterestRecalculationEnabled() { + return this.loanRepaymentScheduleDetail.isInterestRecalculationEnabled(); + } + public LocalDate getMaturityDate() { return this.actualMaturityDate; } 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 ae1f67eb66..c13b33e1f9 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 @@ -281,6 +281,10 @@ private void updateInstallmentIfInterestPeriodPresent(final ProgressiveLoanInter @Override public void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { + // If we are behind, we might need to first recalculate interest + if (ctx instanceof ProgressiveTransactionCtx progressiveTransactionCtx) { + recalculateInterestForDate(loanTransaction.getTransactionDate(), progressiveTransactionCtx); + } switch (loanTransaction.getTypeOf()) { case DISBURSEMENT -> handleDisbursement(loanTransaction, ctx); case WRITEOFF -> handleWriteOff(loanTransaction, ctx); @@ -963,40 +967,44 @@ private List findOverdueInstallmentsBeforeDate } private void recalculateInterestForDate(LocalDate targetDate, ProgressiveTransactionCtx ctx) { - if (ctx.getInstallments() != null && !ctx.getInstallments().isEmpty() - && ctx.getInstallments().get(0).getLoan().getLoanProductRelatedDetail().isInterestRecalculationEnabled() - && !ctx.getInstallments().get(0).getLoan().isNpa() && !ctx.getInstallments().get(0).getLoan().isChargedOff()) { - 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); - } + if (ctx.getInstallments() != null && !ctx.getInstallments().isEmpty()) { + Loan loan = ctx.getInstallments().get(0).getLoan(); + if (loan.isInterestRecalculationEnabled() && !loan.isNpa() + && (!loan.isChargedOff() || !DateUtils.isAfter(targetDate, loan.getChargedOffOnDate()))) { + + 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); - } + 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); + boolean adjustNeeded = !currentInstallment.equals(lastInstallment) || !lastInstallment.isOverdueOn(targetDate); + if (adjustNeeded) { + adjustOverduePrincipalForInstallment(targetDate, currentInstallment, overDuePrincipal, aggregatedOverDuePrincipal, + ctx); + } } } } @@ -1071,9 +1079,6 @@ private void updateInstallmentsPrincipalAndInterestByModel(ProgressiveTransactio } private void handleRepayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { - if (transactionCtx instanceof ProgressiveTransactionCtx) { - recalculateInterestForDate(loanTransaction.getTransactionDate(), (ProgressiveTransactionCtx) transactionCtx); - } if (loanTransaction.isRepaymentLikeType() || loanTransaction.isInterestWaiver() || loanTransaction.isRecoveryRepayment()) { loanTransaction.resetDerivedComponents(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanInterestRecalculationCOBBusinessStep.java b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanInterestRecalculationCOBBusinessStep.java index e19e25f75b..26dceb627e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanInterestRecalculationCOBBusinessStep.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/loan/LoanInterestRecalculationCOBBusinessStep.java @@ -36,7 +36,7 @@ public class LoanInterestRecalculationCOBBusinessStep implements LoanCOBBusiness @Override public Loan execute(Loan loan) { if (!loan.isInterestBearing() || !loan.getStatus().isActive() || loan.isNpa() || loan.isChargedOff() - || !loan.isInterestRecalculationEnabledForProduct() + || !loan.isInterestRecalculationEnabled() || loan.getLoanInterestRecalculationDetails().disallowInterestCalculationOnPastDue()) { log.debug( "Skip processing loan interest recalculation [{}] - Possible reasons: Loan is not an interest bearing loan, Loan is not active, Interest recalculation on past due is disabled on this loan", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanForeclosureValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanForeclosureValidator.java index a4e22403ff..8eba2c376b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanForeclosureValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanForeclosureValidator.java @@ -28,7 +28,7 @@ public final class LoanForeclosureValidator { public void validateForForeclosure(final Loan loan, final LocalDate transactionDate) { - if (loan.isInterestRecalculationEnabledForProduct()) { + if (loan.isInterestRecalculationEnabled()) { final String defaultUserMessage = "The loan with interest recalculation enabled cannot be foreclosed."; throw new LoanForeclosureException("loan.with.interest.recalculation.enabled.cannot.be.foreclosured", defaultUserMessage, loan.getId()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 38fe5ccbcd..a747373c73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -3261,8 +3261,26 @@ public CommandProcessingResult chargeOff(JsonCommand command) { final List existingReversedTransactionIds = loan.findExistingReversedTransactionIds(); LoanTransaction chargeOffTransaction = LoanTransaction.chargeOff(loan, transactionDate, txnExternalId); + + if (loan.isInterestBearing() && loan.isInterestRecalculationEnabled() + && DateUtils.isBefore(loan.getInterestRecalculatedOn(), DateUtils.getBusinessLocalDate())) { + final ScheduleGeneratorDTO scheduleGeneratorDTO = this.loanUtilService.buildScheduleGeneratorDTO(loan, null, null); + loanScheduleService.regenerateRepaymentScheduleWithInterestRecalculation(loan, scheduleGeneratorDTO); + loan.addLoanTransaction(chargeOffTransaction); + ChangedTransactionDetail changedTransactionDetail = loan.reprocessTransactions(); + if (changedTransactionDetail != null) { + for (final Map.Entry mapEntry : changedTransactionDetail.getNewTransactionMappings().entrySet()) { + loanAccountDomainService.saveLoanTransactionWithDataIntegrityViolationChecks(mapEntry.getValue()); + accountTransfersWritePlatformService.updateLoanTransaction(mapEntry.getKey(), mapEntry.getValue()); + } + // Trigger transaction replayed event + replayedTransactionBusinessEventService.raiseTransactionReplayedEvents(changedTransactionDetail); + } + } else { + loan.addLoanTransaction(chargeOffTransaction); + } loanTransactionRepository.saveAndFlush(chargeOffTransaction); - loan.addLoanTransaction(chargeOffTransaction); + saveAndFlushLoanWithDataIntegrityViolationChecks(loan); String noteText = command.stringValueOfParameterNamed(LoanApiConstants.noteParameterName); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanSummaryDataProvider.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanSummaryDataProvider.java index 6bddb5e95b..e8b1b3a199 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanSummaryDataProvider.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanSummaryDataProvider.java @@ -23,6 +23,7 @@ import java.util.Collection; import java.util.List; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.portfolio.loanaccount.data.LoanSummaryData; @@ -42,6 +43,7 @@ @Component @AllArgsConstructor +@Slf4j public class ProgressiveLoanSummaryDataProvider extends CommonLoanSummaryDataProvider { private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor; @@ -89,7 +91,10 @@ public BigDecimal computeTotalUnpaidPayableNotDueInterestAmountOnActualPeriod(fi ProgressiveLoanInterestScheduleModel model = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getRight(); if (!changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft().getCurrentTransactionToOldId().isEmpty() || !changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft().getNewTransactionMappings().isEmpty()) { - throw new RuntimeException("Transactions should not be reverse replayed!"); + List replayedTransactions = changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft() + .getNewTransactionMappings().keySet().stream().toList(); + log.warn("Reprocessed transactions show differences: There are unsaved changes of the following transactions: {}", + replayedTransactions); } if (model != null) { PeriodDueDetails dueAmounts = emiCalculator.getDueAmounts(model, loanRepaymentScheduleInstallment.getDueDate(),