FINERACT-2148: Backdated Charge-off with interest recalculation
This commit is contained in:
@@ -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 |
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
+37
-32
@@ -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();
|
||||
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()))) {
|
||||
|
||||
Optional<LoanRepaymentScheduleInstallment> currentInstallmentOptional = normalInstallments.stream().filter(
|
||||
installment -> installment.getFromDate().isBefore(targetDate) && !installment.getDueDate().isBefore(targetDate))
|
||||
.findAny();
|
||||
List<LoanRepaymentScheduleInstallment> overdueInstallmentsSortedByInstallmentNumber = findOverdueInstallmentsBeforeDateSortedByInstallmentNumber(
|
||||
targetDate, ctx);
|
||||
if (!overdueInstallmentsSortedByInstallmentNumber.isEmpty()) {
|
||||
List<LoanRepaymentScheduleInstallment> normalInstallments = ctx.getInstallments().stream() //
|
||||
.filter(installment -> !installment.isAdditional() && !installment.isDownPayment()).toList();
|
||||
|
||||
// get DUE installment or last installment
|
||||
LoanRepaymentScheduleInstallment lastInstallment = normalInstallments.stream()
|
||||
.max(Comparator.comparing(LoanRepaymentScheduleInstallment::getInstallmentNumber)).get();
|
||||
LoanRepaymentScheduleInstallment currentInstallment = currentInstallmentOptional.orElse(lastInstallment);
|
||||
Optional<LoanRepaymentScheduleInstallment> currentInstallmentOptional = normalInstallments.stream().filter(
|
||||
installment -> installment.getFromDate().isBefore(targetDate) && !installment.getDueDate().isBefore(targetDate))
|
||||
.findAny();
|
||||
|
||||
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);
|
||||
// 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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+19
-1
@@ -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);
|
||||
|
||||
+6
-1
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user