Skip to content

Commit

Permalink
FINERACT-2148: Backdated Charge-off with interest recalculation
Browse files Browse the repository at this point in the history
  • Loading branch information
adamsaghy committed Dec 6, 2024
1 parent a2c01d2 commit 3490219
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,7 @@ public void updateLoanSummaryAndStatus() {
doPostLoanTransactionChecks(getLastUserTransactionDate(), loanLifecycleStateMachine);
}

public boolean isInterestRecalculationEnabledForProduct() {
private boolean isInterestRecalculationEnabledForProduct() {
return this.loanProduct.isInterestRecalculationEnabled();
}

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -963,40 +967,44 @@ private List<LoanRepaymentScheduleInstallment> 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<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);
}
}
}
}
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3261,8 +3261,26 @@ public CommandProcessingResult chargeOff(JsonCommand command) {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,6 +43,7 @@

@Component
@AllArgsConstructor
@Slf4j
public class ProgressiveLoanSummaryDataProvider extends CommonLoanSummaryDataProvider {

private final AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor;
Expand Down Expand Up @@ -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<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(),
Expand Down

0 comments on commit 3490219

Please sign in to comment.