FINERACT-2317: Include capitalized income and buy down fee in available disbursement calculation

This commit is contained in:
Oleksii Novikov
2025-08-06 09:19:23 +02:00
committed by Adam Saghy
parent 9f53440543
commit 25b440b842
4 changed files with 33 additions and 18 deletions
@@ -58,6 +58,7 @@ import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquenc
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
@@ -149,10 +150,7 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
// Overpaid // Overpaid
// loans // loans
collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList); collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList);
// Ensure availableDisbursementAmount is never negative collectionData.setAvailableDisbursementAmount(calculateAvailableDisbursementAmount(loan));
final BigDecimal availableAmount = loan.getApprovedPrincipal().subtract(loan.getDisbursedAmount());
collectionData
.setAvailableDisbursementAmount(availableAmount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : availableAmount);
collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan)); collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan));
final LoanTransaction lastPayment = loan.getLastPaymentTransaction(); final LoanTransaction lastPayment = loan.getLastPaymentTransaction();
@@ -177,6 +175,22 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
return collectionData; return collectionData;
} }
private BigDecimal calculateAvailableDisbursementAmount(@NonNull final Loan loan) {
BigDecimal availableDisbursementAmount = loan.getApprovedPrincipal().subtract(loan.getDisbursedAmount());
if (loan.getLoanRepaymentScheduleDetail().isEnableIncomeCapitalization()) {
final LoanSummary loanSummary = loan.getSummary();
if (loanSummary != null) {
final BigDecimal totalCapitalizedIncome = MathUtil.nullToZero(loanSummary.getTotalCapitalizedIncome());
final BigDecimal totalCapitalizedIncomeAdjustment = MathUtil.nullToZero(loanSummary.getTotalCapitalizedIncomeAdjustment());
final BigDecimal netCapitalizedIncome = totalCapitalizedIncome.subtract(totalCapitalizedIncomeAdjustment);
availableDisbursementAmount = availableDisbursementAmount.subtract(netCapitalizedIncome);
}
}
// Ensure availableDisbursementAmount is never negative
return availableDisbursementAmount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : availableDisbursementAmount;
}
private void addInstallmentLevelDelinquencyData(CollectionData collectionData, Long loanId) { private void addInstallmentLevelDelinquencyData(CollectionData collectionData, Long loanId) {
Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag( Collection<LoanInstallmentDelinquencyTagData> loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag(
loanId); loanId);
@@ -164,7 +164,7 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc
.setAmountAdjustment(MathUtil.nullToZero(capitalizedIncomeBalance.getAmountAdjustment()).add(transactionAmount)); .setAmountAdjustment(MathUtil.nullToZero(capitalizedIncomeBalance.getAmountAdjustment()).add(transactionAmount));
capitalizedIncomeBalance.setUnrecognizedAmount( capitalizedIncomeBalance.setUnrecognizedAmount(
MathUtil.negativeToZero(capitalizedIncomeBalance.getUnrecognizedAmount().subtract(transactionAmount))); MathUtil.negativeToZero(capitalizedIncomeBalance.getUnrecognizedAmount().subtract(transactionAmount)));
capitalizedIncomeBalanceRepository.save(capitalizedIncomeBalance); capitalizedIncomeBalanceRepository.saveAndFlush(capitalizedIncomeBalance);
loanLifecycleStateMachine.determineAndTransition(loan, transactionDate); loanLifecycleStateMachine.determineAndTransition(loan, transactionDate);
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanDisbursalException; import org.apache.fineract.portfolio.loanaccount.exception.LoanDisbursalException;
@@ -35,15 +36,18 @@ public final class LoanDisbursementValidator {
public void compareDisbursedToApprovedOrProposedPrincipal(final Loan loan, final BigDecimal disbursedAmount, public void compareDisbursedToApprovedOrProposedPrincipal(final Loan loan, final BigDecimal disbursedAmount,
final BigDecimal totalDisbursed) { final BigDecimal totalDisbursed) {
final BigDecimal capitalizedIncome = loan.getSummary().getTotalCapitalizedIncome(); final BigDecimal totalCapitalizedIncome = loan.getSummary().getTotalCapitalizedIncome();
final BigDecimal totalCapitalizedIncomeAdjustment = MathUtil.nullToZero(loan.getSummary().getTotalCapitalizedIncomeAdjustment());
final BigDecimal netCapitalizedIncome = totalCapitalizedIncome.subtract(totalCapitalizedIncomeAdjustment);
if (loan.loanProduct().isDisallowExpectedDisbursements() && loan.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) { if (loan.loanProduct().isDisallowExpectedDisbursements() && loan.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) {
validateOverMaximumAmount(loan, totalDisbursed, capitalizedIncome); validateOverMaximumAmount(loan, totalDisbursed, netCapitalizedIncome);
} else { } else {
if (loan.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) { if (loan.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) {
validateOverMaximumAmount(loan, disbursedAmount, capitalizedIncome); validateOverMaximumAmount(loan, disbursedAmount, netCapitalizedIncome);
} else { } else {
if ((totalDisbursed.compareTo(loan.getApprovedPrincipal()) > 0) if ((totalDisbursed.compareTo(loan.getApprovedPrincipal()) > 0)
|| (totalDisbursed.add(capitalizedIncome).compareTo(loan.getApprovedPrincipal()) > 0)) { || (totalDisbursed.add(netCapitalizedIncome).compareTo(loan.getApprovedPrincipal()) > 0)) {
final String errorMsg = "Loan can't be disbursed, disburse amount is exceeding approved principal."; final String errorMsg = "Loan can't be disbursed, disburse amount is exceeding approved principal.";
throw new LoanDisbursalException(errorMsg, "disburse.amount.must.be.less.than.approved.principal", totalDisbursed, throw new LoanDisbursalException(errorMsg, "disburse.amount.must.be.less.than.approved.principal", totalDisbursed,
loan.getApprovedPrincipal()); loan.getApprovedPrincipal());
@@ -65,15 +69,6 @@ public final class LoanDisbursementValidator {
} }
} }
public void validateDisburseAmountNotExceedingApprovedAmount(final Loan loan, final BigDecimal diff,
final BigDecimal principalDisbursed) {
if (!loan.loanProduct().isMultiDisburseLoan() && diff.compareTo(BigDecimal.ZERO) < 0) {
final String errorMsg = "Loan can't be disbursed,disburse amount is exceeding approved amount ";
throw new LoanDisbursalException(errorMsg, "disburse.amount.must.be.less.than.approved.amount", principalDisbursed,
loan.getLoanRepaymentScheduleDetail().getPrincipal().getAmount());
}
}
public void validateDisburseDate(final Loan loan, final LocalDate disbursedOn, final LocalDate expectedDate) { public void validateDisburseDate(final Loan loan, final LocalDate disbursedOn, final LocalDate expectedDate) {
if (expectedDate != null if (expectedDate != null
&& (DateUtils.isAfter(disbursedOn, loan.fetchRepaymentScheduleInstallment(1).getDueDate()) && (DateUtils.isAfter(disbursedOn, loan.fetchRepaymentScheduleInstallment(1).getDueDate())
@@ -100,6 +100,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanChargeService;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionProcessingService; import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionProcessingService;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -367,9 +368,14 @@ public class LoanAccountDelinquencyRangeEventSerializerTest {
LoanProduct loanProduct = Mockito.mock(LoanProduct.class); LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
when(loanProduct.isMultiDisburseLoan()).thenReturn(false); when(loanProduct.isMultiDisburseLoan()).thenReturn(false);
LoanProductRelatedDetail loanProductRelatedDetail = Mockito.mock(LoanProductRelatedDetail.class);
when(loanProductRelatedDetail.isEnableIncomeCapitalization()).thenReturn(false);
Loan loan = Mockito.spy(Loan.class); Loan loan = Mockito.spy(Loan.class);
ReflectionTestUtils.setField(loan, "loanProduct", loanProduct); ReflectionTestUtils.setField(loan, "loanProduct", loanProduct);
ReflectionTestUtils.setField(loan, "loanStatus", LoanStatus.ACTIVE); ReflectionTestUtils.setField(loan, "loanStatus", LoanStatus.ACTIVE);
when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductRelatedDetail);
LoanTransaction transaction1 = Mockito.mock(LoanTransaction.class); LoanTransaction transaction1 = Mockito.mock(LoanTransaction.class);
LoanTransaction transaction2 = Mockito.mock(LoanTransaction.class); LoanTransaction transaction2 = Mockito.mock(LoanTransaction.class);
CollectionData collectionData = Mockito.mock(CollectionData.class); CollectionData collectionData = Mockito.mock(CollectionData.class);