This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 349021957 FINERACT-2148: Backdated Charge-off with interest
recalculation
349021957 is described below
commit 3490219577e0d24c501c8b292c494583b28699b2
Author: Adam Saghy <[email protected]>
AuthorDate: Fri Dec 6 11:00:10 2024 +0100
FINERACT-2148: Backdated Charge-off with interest recalculation
---
.../test/resources/features/LoanChargeOff.feature | 33 ++++++++++
.../portfolio/loanaccount/domain/Loan.java | 6 +-
...dvancedPaymentScheduleTransactionProcessor.java | 75 ++++++++++++----------
.../LoanInterestRecalculationCOBBusinessStep.java | 2 +-
.../serialization/LoanForeclosureValidator.java | 2 +-
.../LoanWritePlatformServiceJpaRepositoryImpl.java | 20 +++++-
.../ProgressiveLoanSummaryDataProvider.java | 7 +-
7 files changed, 105 insertions(+), 40 deletions(-)
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 87596c48f..546cdc657 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 ebc7d9552..cc8039f15 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 class Loan extends
AbstractAuditableWithUTCDateTimeCustom<Long> {
doPostLoanTransactionChecks(getLastUserTransactionDate(),
loanLifecycleStateMachine);
}
- public boolean isInterestRecalculationEnabledForProduct() {
+ private boolean isInterestRecalculationEnabledForProduct() {
return this.loanProduct.isInterestRecalculationEnabled();
}
@@ -2647,6 +2647,10 @@ public class Loan extends
AbstractAuditableWithUTCDateTimeCustom<Long> {
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 ae1f67eb6..c13b33e1f 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 @@ public class AdvancedPaymentScheduleTransactionProcessor
extends AbstractLoanRep
@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 @@ public class AdvancedPaymentScheduleTransactionProcessor
extends AbstractLoanRep
}
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<LoanRepaymentScheduleInstallment>
overdueInstallmentsSortedByInstallmentNumber =
findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(
- targetDate, ctx);
- if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) {
- List<LoanRepaymentScheduleInstallment> normalInstallments =
ctx.getInstallments().stream() //
- .filter(installment -> !installment.isAdditional() &&
!installment.isDownPayment()).toList();
-
- Optional<LoanRepaymentScheduleInstallment>
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<LoanRepaymentScheduleInstallment>
overdueInstallmentsSortedByInstallmentNumber =
findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(
+ targetDate, ctx);
+ if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) {
+ List<LoanRepaymentScheduleInstallment> normalInstallments
= ctx.getInstallments().stream() //
+ .filter(installment -> !installment.isAdditional()
&& !installment.isDownPayment()).toList();
+
+ Optional<LoanRepaymentScheduleInstallment>
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 @@ public class AdvancedPaymentScheduleTransactionProcessor
extends AbstractLoanRep
}
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 e19e25f75..26dceb627 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 a4e22403f..8eba2c376 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 @@ import org.springframework.stereotype.Component;
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 38fe5ccbc..a747373c7 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 class LoanWritePlatformServiceJpaRepositoryImpl
implements LoanWritePlatf
final List<Long> 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<Long, LoanTransaction> 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 6bddb5e95..e8b1b3a19 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.time.LocalDate;
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 @@ import org.springframework.stereotype.Component;
@Component
@AllArgsConstructor
+@Slf4j
public class ProgressiveLoanSummaryDataProvider extends
CommonLoanSummaryDataProvider {
private final AdvancedPaymentScheduleTransactionProcessor
advancedPaymentScheduleTransactionProcessor;
@@ -89,7 +91,10 @@ public class ProgressiveLoanSummaryDataProvider extends
CommonLoanSummaryDataPro
ProgressiveLoanInterestScheduleModel model =
changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getRight();
if
(!changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft().getCurrentTransactionToOldId().isEmpty()
||
!changedTransactionDetailProgressiveLoanInterestScheduleModelPair.getLeft().getNewTransactionMappings().isEmpty())
{
- throw new RuntimeException("Transactions should not be reverse
replayed!");
+ List<Long> 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(),