diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java index a49b5dee7f5..8e878d55919 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java @@ -156,7 +156,7 @@ public CollectionData calculateLoanCollectionData(final Long loanId) { // If the Loan is not Active yet or is cancelled (rejected or withdrawn), return template data if (loan.isSubmittedAndPendingApproval() || loan.isApproved() || loan.isCancelled()) { - if (loan.getLoanProduct() == null || loan.getLoanProduct().isAllowApprovedDisbursedAmountsOverApplied()) { + if (loan.getLoanProduct() != null && loan.getLoanProduct().isAllowApprovedDisbursedAmountsOverApplied()) { collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); } return collectionData; @@ -173,7 +173,9 @@ public CollectionData calculateLoanCollectionData(final Long loanId) { // loans collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList); collectionData.setAvailableDisbursementAmount(calculateAvailableDisbursementAmount(loan)); - collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); + if (loan.getLoanProduct() != null) { + collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); + } collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan)); PossibleNextRepaymentCalculationService possibleNextRepaymentCalculationService = possibleNextRepaymentCalculationServiceDiscovery .getService(loan); @@ -212,7 +214,7 @@ public BigDecimal calculateAvailableDisbursementAmountWithOverApplied(@NonNull f BigDecimal approvedWithOverApplied = loan.getApprovedPrincipal(); // If over applied amount is enabled, calculate the maximum allowed amount - if (loanProduct.isAllowApprovedDisbursedAmountsOverApplied()) { + if (loanProduct != null && loanProduct.isAllowApprovedDisbursedAmountsOverApplied()) { if (loanProduct.getOverAppliedCalculationType() != null) { if ("percentage".equalsIgnoreCase(loanProduct.getOverAppliedCalculationType())) { final BigDecimal overAppliedNumber = BigDecimal.valueOf(loanProduct.getOverAppliedNumber()); diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImplTest.java index dd8d3c01c00..53ad01cfe82 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImplTest.java @@ -20,29 +20,49 @@ import static java.time.Month.JANUARY; import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; import java.time.LocalDate; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Optional; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucketRepository; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository; import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction; import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyActionRepository; import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository; import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository; +import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper; import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper; import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper; import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper; import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; import org.apache.fineract.portfolio.loanaccount.data.DelinquencyPausePeriod; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -50,7 +70,6 @@ class DelinquencyReadPlatformServiceImplTest { @Mock private DelinquencyRangeRepository repositoryRange; - @Mock private DelinquencyBucketRepository repositoryBucket; @Mock @@ -59,21 +78,24 @@ class DelinquencyReadPlatformServiceImplTest { private DelinquencyRangeMapper mapperRange; @Mock private DelinquencyBucketMapper mapperBucket; - @Mock private LoanDelinquencyTagMapper mapperLoanDelinquencyTagHistory; - @Mock private LoanRepository loanRepository; - @Mock private LoanDelinquencyDomainService loanDelinquencyDomainService; - @Mock private LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag; - @Mock private LoanDelinquencyActionRepository loanDelinquencyActionRepository; + @Mock + private ConfigurationDomainService configurationDomainService; + @Mock + private LoanTransactionRepository loanTransactionRepository; + @Mock + private PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationServiceDiscovery; + @Mock + private DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper; @InjectMocks private DelinquencyReadPlatformServiceImpl underTest; @@ -157,6 +179,55 @@ public void testMultiplePausesWithoutResumeActionCurrentBusinessDateBetweenStart ); } + @Test + void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { + Loan loan = mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(null); + when(loan.isSubmittedAndPendingApproval()).thenReturn(true); + // REMOVED: when(loan.isApproved()).thenReturn(false); ← unnecessary + // REMOVED: when(loan.isCancelled()).thenReturn(false); ← unnecessary + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + + CollectionData result = underTest.calculateLoanCollectionData(1L); + + assertThat(result).isNotNull(); + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { + HashMap businessDates = new HashMap<>(); + businessDates.put(BusinessDateType.BUSINESS_DATE, LocalDate.of(2024, 1, 1)); + businessDates.put(BusinessDateType.COB_DATE, LocalDate.of(2024, 1, 1)); + ThreadLocalContextUtil.setBusinessDates(businessDates); + + try { + Loan loan = mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(null); + when(loan.isSubmittedAndPendingApproval()).thenReturn(false); + when(loan.isApproved()).thenReturn(false); + when(loan.isCancelled()).thenReturn(false); + when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getDisbursedAmount()).thenReturn(BigDecimal.valueOf(5000)); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(mock(LoanProductRelatedDetail.class)); + when(loanDelinquencyDomainService.getOverdueCollectionData(any(), any())).thenReturn(CollectionData.template()); + when(loanDelinquencyActionRepository.findByLoanOrderById(any())).thenReturn(List.of()); + when(configurationDomainService.getNextPaymentDateConfigForLoan()).thenReturn(null); + when(possibleNextRepaymentCalculationServiceDiscovery.getService(any())).thenReturn(null); + when(loan.getLastPaymentTransaction()).thenReturn(null); + when(loan.getLastRepaymentOrDownPaymentTransaction()).thenReturn(null); + when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(false); + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + + CollectionData result = underTest.calculateLoanCollectionData(1L); + + assertThat(result).isNotNull(); + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); + } finally { + ThreadLocalContextUtil.reset(); + } + } + @Test public void testMultiplePausesWithoutResumeCurrentBusinessDateIsNotOverlappingWithAnyOfThePauses() { // given @@ -180,6 +251,62 @@ public void testMultiplePausesWithoutResumeCurrentBusinessDateIsNotOverlappingWi ); } + @Test + void givenLoanWithNullProduct_whenHelperCalledDirectly_thenReturnsZero() { + Loan loan = mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(null); + when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getDisbursedAmount()).thenReturn(BigDecimal.valueOf(5000)); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(mock(LoanProductRelatedDetail.class)); + + BigDecimal result = underTest.calculateAvailableDisbursementAmountWithOverApplied(loan); + + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(5000)); + } + + @Test + void givenLoanWithProductOverApplyDisabled_whenHelperCalledDirectly_thenReturnsApprovedMinusDisbursed() { + Loan loan = mock(Loan.class); + LoanProduct loanProduct = mock(LoanProduct.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loanProduct.isAllowApprovedDisbursedAmountsOverApplied()).thenReturn(false); + when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getDisbursedAmount()).thenReturn(BigDecimal.valueOf(4000)); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(mock(LoanProductRelatedDetail.class)); + + BigDecimal result = underTest.calculateAvailableDisbursementAmountWithOverApplied(loan); + + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(6000)); + } + + @Test + void givenLoanWithPercentageOverApply_whenHelperCalledDirectly_thenReturnsCalculatedAmount() { + // MoneyHelper.getMathContext() requires a tenant context + MathContext mathContext = new MathContext(19, RoundingMode.HALF_EVEN); + MockedStatic moneyHelperMock = mockStatic(MoneyHelper.class); + moneyHelperMock.when(MoneyHelper::getMathContext).thenReturn(mathContext); + + try { + Loan loan = mock(Loan.class); + LoanProduct loanProduct = mock(LoanProduct.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loanProduct.isAllowApprovedDisbursedAmountsOverApplied()).thenReturn(true); + when(loanProduct.getOverAppliedCalculationType()).thenReturn("percentage"); + when(loanProduct.getOverAppliedNumber()).thenReturn(10); + when(loan.getProposedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getDisbursedAmount()).thenReturn(BigDecimal.ZERO); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(mock(LoanProductRelatedDetail.class)); + + BigDecimal result = underTest.calculateAvailableDisbursementAmountWithOverApplied(loan); + + // 10000 * (1 + 10/100) = 11000, minus 0 disbursed = 11000 + assertThat(result).isEqualByComparingTo(BigDecimal.valueOf(11000)); + } finally { + moneyHelperMock.close(); + } + } + private void verifyPausePeriods(CollectionData collectionData, DelinquencyPausePeriod... pausePeriods) { if (pausePeriods.length > 0) { Assertions.assertEquals(Arrays.asList(pausePeriods), collectionData.getDelinquencyPausePeriods());