From ee72978a49a89f641fb5b9322485f3e06c5d1523 Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Mon, 27 Feb 2023 19:45:07 +0100 Subject: [PATCH] FINERACT-1840: Fee income is not realised if the loan got fully repaid --- .../portfolio/loanaccount/domain/Loan.java | 43 +-------- .../domain/LoanAccountDomainService.java | 2 + .../domain/LoanAccountDomainServiceJpa.java | 92 +++++++++++++++++++ .../loanaccount/domain/LoanCharge.java | 2 +- .../domain/LoanInstallmentCharge.java | 2 +- .../LoanStatusChangePlatformServiceImpl.java | 9 +- ...ntDelinquencyRangeEventSerializerTest.java | 2 +- .../ClientLoanIntegrationTest.java | 88 ++++++++++++++++++ 8 files changed, 195 insertions(+), 45 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index f65136457..eca63486e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -3506,43 +3506,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { updateLoanOutstandingBalances(); } - public void applyIncomeAccrualTransaction(LocalDate closedDate) { - ExternalId externalId = ExternalId.empty(); - boolean isExternalIdAutoGenerationEnabled = TemporaryConfigurationServiceContainer.isExternalIdAutoGenerationEnabled(); - if (isPeriodicAccrualAccountingEnabledOnLoanProduct() - // to avoid collision with processIncomeAccrualTransactionOnLoanClosure() - // TODO: review after interest calculation implemented - && !(this.loanInterestRecalculationDetails != null - && this.loanInterestRecalculationDetails.isCompoundingToBePostedAsTransaction())) { - List updatedAccrualTransactions = retrieveListOfAccrualTransactions(); - LocalDate lastAccruedDate = this.getDisbursementDate(); - if (!updatedAccrualTransactions.isEmpty()) { - lastAccruedDate = updatedAccrualTransactions.get(updatedAccrualTransactions.size() - 1).getTransactionDate(); - } - HashMap feeDetails = new HashMap<>(); - determineFeeDetails(lastAccruedDate, closedDate, feeDetails); - if (isExternalIdAutoGenerationEnabled) { - externalId = ExternalId.generate(); - } - BigDecimal fee = (BigDecimal) feeDetails.get(FEE); - if (fee == null) { - fee = BigDecimal.ZERO; - } - BigDecimal penalty = (BigDecimal) feeDetails.get(PENALTIES); - if (penalty == null) { - penalty = BigDecimal.ZERO; - } - BigDecimal total = fee.add(penalty); - // TODO: calculate interest? - if (total.compareTo(BigDecimal.ZERO) > 0) { - LoanTransaction accrualTransaction = LoanTransaction.accrueTransaction(this, this.getOffice(), closedDate, total, null, fee, - penalty, externalId); - updateLoanChargesPaidBy(accrualTransaction, feeDetails, null); - addLoanTransaction(accrualTransaction); - } - } - } - private void determineCumulativeIncomeFromInstallments(HashMap cumulativeIncomeFromInstallments) { BigDecimal interest = BigDecimal.ZERO; BigDecimal fee = BigDecimal.ZERO; @@ -5484,7 +5447,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { for (LoanInstallmentCharge loanInstallmentCharge : loanInstallmentCharges) { Integer installmentNumber = null == loanInstallmentCharge.getInstallment() ? null : loanInstallmentCharge.getInstallment().getInstallmentNumber(); - final LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(accrual, loanInstallmentCharge.getLoancharge(), + final LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(accrual, loanInstallmentCharge.getLoanCharge(), loanInstallmentCharge.getAmount(getCurrency()).getAmount(), installmentNumber); accrual.getLoanChargesPaid().add(loanChargePaidBy); } @@ -7060,4 +7023,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { public LocalDate getChargedOffOnDate() { return chargedOffOnDate; } + + public LoanInterestRecalculationDetails getLoanInterestRecalculationDetails() { + return loanInterestRecalculationDetails; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java index e10c3a841..1eb1bb464 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainService.java @@ -98,4 +98,6 @@ public interface LoanAccountDomainService { LoanTransaction creditBalanceRefund(Loan loan, LocalDate transactionDate, BigDecimal transactionAmount, String noteText, ExternalId externalId, PaymentDetail paymentDetail); + + void applyIncomeAccrualTransaction(Loan loan); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java index c402fc1a9..8351a8070 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -61,6 +62,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionRecoveryPaymentPostBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionRecoveryPaymentPreBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.interoperation.util.MathUtil; import org.apache.fineract.organisation.holiday.domain.Holiday; import org.apache.fineract.organisation.holiday.domain.HolidayRepository; import org.apache.fineract.organisation.holiday.domain.HolidayStatusType; @@ -865,4 +867,94 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { } } + @Override + public void applyIncomeAccrualTransaction(Loan loan) { + if (loan.isPeriodicAccrualAccountingEnabledOnLoanProduct() + // to avoid collision with processIncomeAccrualTransactionOnLoanClosure() + && !(loan.getLoanInterestRecalculationDetails() != null + && loan.getLoanInterestRecalculationDetails().isCompoundingToBePostedAsTransaction())) { + + MonetaryCurrency currency = loan.getCurrency(); + Money interestPortion = Money.zero(currency); + Money feePortion = Money.zero(currency); + Money penaltyPortion = Money.zero(currency); + + for (LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment : loan.getRepaymentScheduleInstallments()) { + interestPortion = interestPortion.add(loanRepaymentScheduleInstallment.getInterestCharged(currency)) + .minus(loanRepaymentScheduleInstallment.getInterestAccrued(currency)) + .minus(loanRepaymentScheduleInstallment.getInterestWaived(currency)); + feePortion = feePortion.add(loanRepaymentScheduleInstallment.getFeeChargesCharged(currency)) + .minus(loanRepaymentScheduleInstallment.getFeeAccrued(currency)) + .minus(loanRepaymentScheduleInstallment.getFeeChargesWaived(currency)); + penaltyPortion = penaltyPortion.add(loanRepaymentScheduleInstallment.getPenaltyChargesCharged(currency)) + .minus(loanRepaymentScheduleInstallment.getPenaltyAccrued(currency)) + .minus(loanRepaymentScheduleInstallment.getPenaltyChargesWaived(currency)); + } + Money total = interestPortion.plus(feePortion).plus(penaltyPortion); + + if (total.isGreaterThanZero()) { + ExternalId externalId = externalIdFactory.create(); + + LoanTransaction accrualTransaction = LoanTransaction.accrueTransaction(loan, loan.getOffice(), loan.getClosedOnDate(), + total.getAmount(), interestPortion.getAmount(), feePortion.getAmount(), penaltyPortion.getAmount(), externalId); + + Set accrualCharges = accrualTransaction.getLoanChargesPaid(); + + Map accrualDetails = loan.getActiveCharges().stream() + .collect(Collectors.toMap(LoanCharge::getId, v -> Money.zero(currency))); + + loan.getLoanTransactions(LoanTransaction::isAccrual).forEach(transaction -> { + transaction.getLoanChargesPaid().forEach(loanChargePaid -> { + accrualDetails.computeIfPresent(loanChargePaid.getLoanCharge().getId(), + (mappedKey, mappedValue) -> mappedValue.add(Money.of(currency, loanChargePaid.getAmount()))); + }); + }); + + loan.getActiveCharges().forEach(loanCharge -> { + Money amount = loanCharge.getAmount(currency).minus(loanCharge.getAmountWaived(currency)); + if (!loanCharge.isInstalmentFee() && loanCharge.isActive() + && accrualDetails.get(loanCharge.getId()).isLessThan(amount)) { + Money amountToBeAccrued = amount.minus(accrualDetails.get(loanCharge.getId())); + final LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(accrualTransaction, loanCharge, + amountToBeAccrued.getAmount(), null); + accrualCharges.add(loanChargePaidBy); + } + }); + + for (LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment : loan.getRepaymentScheduleInstallments()) { + for (LoanInstallmentCharge installmentCharge : loanRepaymentScheduleInstallment.getInstallmentCharges()) { + if (installmentCharge.getLoanCharge().isActive()) { + Money notWaivedAmount = installmentCharge.getAmount(currency) + .minus(installmentCharge.getAmountWaived(currency)); + if (notWaivedAmount.isGreaterThanZero()) { + Money amountToBeAccrued = notWaivedAmount + .minus(accrualDetails.get(installmentCharge.getLoanCharge().getId())); + if (amountToBeAccrued.isGreaterThanZero()) { + final LoanChargePaidBy loanChargePaidBy = new LoanChargePaidBy(accrualTransaction, + installmentCharge.getLoanCharge(), amountToBeAccrued.getAmount(), + installmentCharge.getInstallment().getInstallmentNumber()); + accrualCharges.add(loanChargePaidBy); + accrualDetails.computeIfPresent(installmentCharge.getLoanCharge().getId(), + (mappedKey, mappedValue) -> mappedValue.add(amountToBeAccrued)); + } + accrualDetails.computeIfPresent(installmentCharge.getLoanCharge().getId(), + (mappedKey, mappedValue) -> MathUtil + .negativeToZero(mappedValue.minus(Money.of(currency, installmentCharge.getAmount())))); + } + } + } + } + saveLoanTransactionWithDataIntegrityViolationChecks(accrualTransaction); + loan.addLoanTransaction(accrualTransaction); + + loan.getRepaymentScheduleInstallments().forEach(installment -> { + installment.updateAccrualPortion( + installment.getInterestCharged(currency).minus(installment.getInterestWaived(currency)), + installment.getFeeChargesCharged(currency).minus(installment.getFeeChargesWaived(currency)), + installment.getPenaltyChargesCharged(currency).minus(installment.getPenaltyChargesWaived(currency))); + }); + } + } + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java index 3ab6dbb79..601c0bacd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanCharge.java @@ -140,7 +140,7 @@ public class LoanCharge extends AbstractPersistableCustom { private LoanTrancheDisbursementCharge loanTrancheDisbursementCharge; @OneToMany(mappedBy = "loanCharge", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - private Set loanChargePaidBySet; + private Set loanChargePaidBySet = new HashSet<>(); protected LoanCharge() { // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanInstallmentCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanInstallmentCharge.java index ca1190b6e..e40d98247 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanInstallmentCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanInstallmentCharge.java @@ -312,7 +312,7 @@ public class LoanInstallmentCharge extends AbstractPersistableCustom implements return amountToDeductOnThisCharge; } - public LoanCharge getLoancharge() { + public LoanCharge getLoanCharge() { return this.loancharge; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java index d34c98f8b..4f3dab667 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanStatusChangePlatformServiceImpl.java @@ -25,6 +25,7 @@ import org.apache.fineract.infrastructure.event.business.BusinessEventListener; import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainServiceJpa; import org.springframework.stereotype.Service; @Slf4j @@ -33,22 +34,22 @@ import org.springframework.stereotype.Service; public class LoanStatusChangePlatformServiceImpl implements LoanStatusChangePlatformService { private final BusinessEventNotifierService businessEventNotifierService; + private final LoanAccountDomainServiceJpa loanAccountDomainService; @PostConstruct public void addListeners() { businessEventNotifierService.addPostBusinessEventListener(LoanStatusChangedBusinessEvent.class, new LoanStatusChangedListener()); } - private static class LoanStatusChangedListener implements BusinessEventListener { + private class LoanStatusChangedListener implements BusinessEventListener { @Override public void onBusinessEvent(LoanStatusChangedBusinessEvent event) { final Loan loan = event.get(); log.debug("Loan Status change for loan {}", loan.getId()); - // Apply common actions on Loan account closed - if (loan.isClosed()) { + if (loan.getStatus().isClosedObligationsMet()) { log.debug("Loan Status {} for loan {}", loan.getStatus().getCode(), loan.getId()); - loan.applyIncomeAccrualTransaction(loan.getClosedOnDate()); + loanAccountDomainService.applyIncomeAccrualTransaction(loan); } if (loan.isOpen()) { loan.handleMaturityDateActivate(); 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 c92d22c4b..22995aaa7 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 @@ -133,7 +133,7 @@ public class LoanAccountDelinquencyRangeEventSerializerTest { BigDecimal.valueOf(30), BigDecimal.valueOf(50), BigDecimal.valueOf(185), new BigDecimal("100.5"), new BigDecimal("200.3"))); when(loanForProcessing.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments); when(loanChargeReadPlatformService.retrieveLoanCharges(anyLong())).thenAnswer(a -> repaymentScheduleInstallments.get(0) - .getInstallmentCharges().stream().map(c -> c.getLoancharge().toData()).collect(Collectors.toList())); + .getInstallmentCharges().stream().map(c -> c.getLoanCharge().toData()).collect(Collectors.toList())); moneyHelper.when(() -> MoneyHelper.getRoundingMode()).thenReturn(RoundingMode.UP); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java index 64cf20c4a..b410ca345 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java @@ -7669,6 +7669,94 @@ public class ClientLoanIntegrationTest { } } + @Test + public void accrualIsCalculatedWhenTheLoanIsClosed() { + try { + GlobalConfigurationHelper.updateIsAutomaticExternalIdGenerationEnabled(this.requestSpec, this.responseSpec, true); + GlobalConfigurationHelper.updateIsBusinessDateEnabled(this.requestSpec, this.responseSpec, true); + businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BusinessDateType.BUSINESS_DATE.getName()) + .date("2022.10.10").dateFormat("yyyy.MM.dd").locale("en")); + + final Account assetAccount = this.accountHelper.createAssetAccount(); + final Account incomeAccount = this.accountHelper.createIncomeAccount(); + final Account expenseAccount = this.accountHelper.createExpenseAccount(); + final Account overpaymentAccount = this.accountHelper.createLiabilityAccount(); + + final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingNoInterest(assetAccount, incomeAccount, + expenseAccount, overpaymentAccount); + + final Integer clientID = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2011"); + List charges = new ArrayList<>(); + Integer installmentFee = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanInstallmentJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "10", false)); + addCharges(charges, installmentFee, "10", null); + + final Integer loanID = applyForLoanApplication(clientID, loanProductID, charges); + + HashMap loanStatusHashMap = LoanStatusChecker.getStatusOfLoan(requestSpec, responseSpec, loanID); + LoanStatusChecker.verifyLoanIsPending(loanStatusHashMap); + + loanStatusHashMap = this.loanTransactionHelper.approveLoan("02 September 2022", loanID); + LoanStatusChecker.verifyLoanIsApproved(loanStatusHashMap); + LoanStatusChecker.verifyLoanIsWaitingForDisbursal(loanStatusHashMap); + + loanStatusHashMap = this.loanTransactionHelper.disburseLoanWithNetDisbursalAmount("03 September 2022", loanID, "1000"); + LoanStatusChecker.verifyLoanIsActive(loanStatusHashMap); + + this.loanTransactionHelper.makeRepayment("04 September 2022", Float.parseFloat("5"), loanID); + + this.periodicAccrualAccountingHelper.runPeriodicAccrualAccounting("04 September 2022"); + + Integer penalty = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, "11", true)); + LocalDate targetDate = LocalDate.of(2022, 9, 6); + final String penaltyCharge1AddedDate = dateFormatter.format(targetDate); + + Integer penalty1LoanChargeId = this.loanTransactionHelper.addChargesForLoan(loanID, + LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(penalty), penaltyCharge1AddedDate, "11")); + + this.loanTransactionHelper.waiveLoanCharge((long) loanID, (long) penalty1LoanChargeId, + new PostLoansLoanIdChargesChargeIdRequest()); + + this.loanTransactionHelper.makeRepayment("08 September 2022", Float.parseFloat("1010"), loanID); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanID); + + GetLoansLoanIdTransactions lastAccrualTransaction = loanDetails.getTransactions().stream() + .filter(t -> Boolean.TRUE.equals(t.getType().getAccrual())).findFirst().get(); + assertEquals(15, lastAccrualTransaction.getAmount()); + assertEquals(5, lastAccrualTransaction.getPenaltyChargesPortion()); + assertEquals(10, lastAccrualTransaction.getFeeChargesPortion()); + + GetLoansLoanIdTransactionsTransactionIdResponse accrualTransactionDetails = this.loanTransactionHelper + .getLoanTransactionDetails((long) loanID, lastAccrualTransaction.getId()); + + assertEquals(2, accrualTransactionDetails.getLoanChargePaidByList().size()); + accrualTransactionDetails.getLoanChargePaidByList().forEach(loanCharge -> { + if (loanCharge.getChargeId().equals((long) penalty1LoanChargeId)) { + assertEquals(5, loanCharge.getAmount()); + } else { + assertEquals(10, loanCharge.getAmount()); + } + }); + + } finally { + GlobalConfigurationHelper.updateIsAutomaticExternalIdGenerationEnabled(this.requestSpec, this.responseSpec, false); + GlobalConfigurationHelper.updateIsBusinessDateEnabled(this.requestSpec, this.responseSpec, false); + } + } + + private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID, final List charges) { + LOG.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------"); + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("1") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("1").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("0").withInterestTypeAsFlatBalance() + .withAmortizationTypeAsEqualPrincipalPayments().withInterestCalculationPeriodTypeSameAsRepaymentPeriod() + .withCharges(charges).withExpectedDisbursementDate("03 September 2022").withSubmittedOnDate("01 September 2022") + .withLoanType("individual").build(clientID.toString(), loanProductID.toString(), null); + return this.loanTransactionHelper.getLoanId(loanApplicationJSON); + } + private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID) { LOG.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------"); final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("1")