FINERACT-1840: Fee income is not realised if the loan got fully repaid
This commit is contained in:
committed by
Arnold Gálovics
parent
c1570049ed
commit
ee72978a49
+5
-38
@@ -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<LoanTransaction> updatedAccrualTransactions = retrieveListOfAccrualTransactions();
|
||||
LocalDate lastAccruedDate = this.getDisbursementDate();
|
||||
if (!updatedAccrualTransactions.isEmpty()) {
|
||||
lastAccruedDate = updatedAccrualTransactions.get(updatedAccrualTransactions.size() - 1).getTransactionDate();
|
||||
}
|
||||
HashMap<String, Object> 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<String, BigDecimal> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+92
@@ -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<LoanChargePaidBy> accrualCharges = accrualTransaction.getLoanChargesPaid();
|
||||
|
||||
Map<Long, Money> 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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -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<LoanChargePaidBy> loanChargePaidBySet;
|
||||
private Set<LoanChargePaidBy> loanChargePaidBySet = new HashSet<>();
|
||||
|
||||
protected LoanCharge() {
|
||||
//
|
||||
|
||||
+1
-1
@@ -312,7 +312,7 @@ public class LoanInstallmentCharge extends AbstractPersistableCustom implements
|
||||
return amountToDeductOnThisCharge;
|
||||
}
|
||||
|
||||
public LoanCharge getLoancharge() {
|
||||
public LoanCharge getLoanCharge() {
|
||||
return this.loancharge;
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -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<LoanStatusChangedBusinessEvent> {
|
||||
private class LoanStatusChangedListener implements BusinessEventListener<LoanStatusChangedBusinessEvent> {
|
||||
|
||||
@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();
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+88
@@ -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<HashMap> 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<String, Object> 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<HashMap> 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")
|
||||
|
||||
Reference in New Issue
Block a user