From 00045bb344fb95bcf300f21b44539e8227348baa Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Tue, 28 Apr 2026 12:16:41 +0100 Subject: [PATCH 01/10] FINERACT-2593: Fix inverted null guard and add self-defensive NPE protection in DelinquencyReadPlatformServiceImpl --- .../DelinquencyReadPlatformServiceImpl.java | 6 +- ...elinquencyReadPlatformServiceImplTest.java | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) 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..2e484ba7bf9 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); 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..f2615f3a565 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,11 +20,22 @@ 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.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import java.math.BigDecimal; 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.portfolio.delinquency.domain.DelinquencyBucketRepository; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRangeRepository; import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction; @@ -37,7 +48,10 @@ 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.LoanProductRelatedDetail; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -72,6 +86,15 @@ class DelinquencyReadPlatformServiceImplTest { @Mock private LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag; + @Mock + private ConfigurationDomainService configurationDomainService; + + @Mock + private LoanTransactionRepository loanTransactionRepository; + + @Mock + private PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationServiceDiscovery; + @Mock private LoanDelinquencyActionRepository loanDelinquencyActionRepository; @@ -192,4 +215,57 @@ private DelinquencyPausePeriod pausePeriod(boolean active, String startDate, Str return new DelinquencyPausePeriod(active, LocalDate.parse(startDate), LocalDate.parse(endDate)); } + @Test + void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { + Loan loan = mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(null); + when(loan.isSubmittedAndPendingApproval()).thenReturn(true); + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + + // When product is null, guard prevents calling helper → no exception, returns template + assertThatCode(() -> underTest.calculateLoanCollectionData(1L)).doesNotThrowAnyException(); + } + + @Test + void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndAvailableDisbursementAmountIsSet() { + 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); + + // calculateAvailableDisbursementAmount() is always called for active loans + when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getDisbursedAmount()).thenReturn(BigDecimal.valueOf(5000)); + LoanProductRelatedDetail detail = mock(LoanProductRelatedDetail.class); + when(detail.isEnableIncomeCapitalization()).thenReturn(false); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(detail); + + 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(); + // calculateAvailableDisbursementAmount = 10000 - 5000 = 5000 + assertThat(result.getAvailableDisbursementAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000)); + // LoanProduct is null → over-applied helper skipped → field stays at template default + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); + } finally { + ThreadLocalContextUtil.reset(); + } + } + } From 60edda93c89e5c40883e6da61a2db8ec9514554b Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Tue, 28 Apr 2026 12:27:37 +0100 Subject: [PATCH 02/10] FINERACT-2593: Fix inverted null guard and add self-defensive NPE protection in DelinquencyReadPlatformServiceImpl --- .../service/DelinquencyReadPlatformServiceImplTest.java | 5 +++++ 1 file changed, 5 insertions(+) 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 f2615f3a565..82acc9f0ac1 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 @@ -94,9 +94,13 @@ class DelinquencyReadPlatformServiceImplTest { @Mock private PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationServiceDiscovery; +<<<<<<< HEAD @Mock private LoanDelinquencyActionRepository loanDelinquencyActionRepository; +======= + +>>>>>>> 3264fbce5 (FINERACT-2593: Fix inverted null guard and add self-defensive NPE protection in DelinquencyReadPlatformServiceImpl) @InjectMocks private DelinquencyReadPlatformServiceImpl underTest; @@ -269,3 +273,4 @@ void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExcept } } + From ccd1ff78d6e86651ffabed75303974aa25a0ab6b Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Tue, 28 Apr 2026 12:44:35 +0100 Subject: [PATCH 03/10] =?UTF-8?q?FINERACT-2593:=20Address=20Copilot=20revi?= =?UTF-8?q?ew=20=E2=80=94=20add=20DelinquencyEffectivePauseHelper=20mock,?= =?UTF-8?q?=20fix=20import=20formatting,=20add=20null-product=20regression?= =?UTF-8?q?=20tests,=20extend=20active-loan=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DelinquencyReadPlatformServiceImpl.java | 2 +- ...elinquencyReadPlatformServiceImplTest.java | 55 +++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) 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 2e484ba7bf9..82263d0bbef 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 @@ -173,7 +173,7 @@ public CollectionData calculateLoanCollectionData(final Long loanId) { // loans collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList); collectionData.setAvailableDisbursementAmount(calculateAvailableDisbursementAmount(loan)); - if (loan.getLoanProduct() != null) { + if (loan.getLoanProduct() != null && loan.getLoanProduct().isAllowApprovedDisbursedAmountsOverApplied()) { collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); } collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan)); 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 82acc9f0ac1..b7fd9978fc1 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,6 +20,8 @@ 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.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; @@ -32,8 +34,10 @@ 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.portfolio.delinquency.domain.DelinquencyBucketRepository; @@ -42,6 +46,7 @@ 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; @@ -64,7 +69,6 @@ class DelinquencyReadPlatformServiceImplTest { @Mock private DelinquencyRangeRepository repositoryRange; - @Mock private DelinquencyBucketRepository repositoryBucket; @Mock @@ -73,34 +77,34 @@ 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 ConfigurationDomainService configurationDomainService; + + private LoanDelinquencyActionRepository loanDelinquencyActionRepository; + @Mock + private ConfigurationDomainService configurationDomainService; @Mock private LoanTransactionRepository loanTransactionRepository; - @Mock private PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationServiceDiscovery; -<<<<<<< HEAD + + @Mock private LoanDelinquencyActionRepository loanDelinquencyActionRepository; -======= ->>>>>>> 3264fbce5 (FINERACT-2593: Fix inverted null guard and add self-defensive NPE protection in DelinquencyReadPlatformServiceImpl) + + @Mock + private DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper; @InjectMocks private DelinquencyReadPlatformServiceImpl underTest; @@ -184,6 +188,37 @@ public void testMultiplePausesWithoutResumeActionCurrentBusinessDateBetweenStart ); } + @Test + void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { + Loan loan = mock(Loan.class); + when(loan.getLoanProduct()).thenReturn(null); + when(loan.isSubmittedAndPendingApproval()).thenReturn(true); + when(loan.isApproved()).thenReturn(false); + when(loan.isCancelled()).thenReturn(false); + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + + CollectionData result = underTest.calculateLoanCollectionData(1L); + + assertThat(result).isNotNull(); + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isNull(); + } + + @Test + void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { + 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(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + when(loanDelinquencyDomainService.getOverdueCollectionData(any(), any())).thenReturn(CollectionData.template()); + + CollectionData result = underTest.calculateLoanCollectionData(1L); + + assertThat(result).isNotNull(); + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isNull(); + } + @Test public void testMultiplePausesWithoutResumeCurrentBusinessDateIsNotOverlappingWithAnyOfThePauses() { // given From 1c3a31f63c1612ea25e6fa813cb7785e63a1203a Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Tue, 28 Apr 2026 21:57:24 +0100 Subject: [PATCH 04/10] FINERACT-2593: Add null guard in helper method and direct unit tests for calculateAvailableDisbursementAmountWithOverApplied --- .../DelinquencyReadPlatformServiceImpl.java | 2 +- ...elinquencyReadPlatformServiceImplTest.java | 129 ++++++++++++++---- 2 files changed, 101 insertions(+), 30 deletions(-) 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 82263d0bbef..be07d7d9d29 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 @@ -214,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 b7fd9978fc1..9066e8c1988 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 @@ -17,29 +17,34 @@ * under the License. */ package org.apache.fineract.portfolio.delinquency.service; - -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.assertj.core.api.Assertions.assertThatCode; +import static java.time.Month.JANUARY; +import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE; 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 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; @@ -56,12 +61,14 @@ 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) @@ -87,22 +94,12 @@ class DelinquencyReadPlatformServiceImplTest { private LoanInstallmentDelinquencyTagRepository repositoryLoanInstallmentDelinquencyTag; @Mock private ConfigurationDomainService configurationDomainService; - - - private LoanDelinquencyActionRepository loanDelinquencyActionRepository; - @Mock - private ConfigurationDomainService configurationDomainService; @Mock private LoanTransactionRepository loanTransactionRepository; @Mock private PossibleNextRepaymentCalculationServiceDiscovery possibleNextRepaymentCalculationServiceDiscovery; - - - @Mock private LoanDelinquencyActionRepository loanDelinquencyActionRepository; - - @Mock private DelinquencyEffectivePauseHelper delinquencyEffectivePauseHelper; @@ -193,30 +190,48 @@ void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExcep Loan loan = mock(Loan.class); when(loan.getLoanProduct()).thenReturn(null); when(loan.isSubmittedAndPendingApproval()).thenReturn(true); - when(loan.isApproved()).thenReturn(false); - when(loan.isCancelled()).thenReturn(false); + // 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()).isNull(); + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); } @Test void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { - 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(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); - when(loanDelinquencyDomainService.getOverdueCollectionData(any(), any())).thenReturn(CollectionData.template()); + 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); - CollectionData result = underTest.calculateLoanCollectionData(1L); + 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)); - assertThat(result).isNotNull(); - assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isNull(); + CollectionData result = underTest.calculateLoanCollectionData(1L); + + assertThat(result).isNotNull(); + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); + } finally { + ThreadLocalContextUtil.reset(); + } } @Test @@ -242,6 +257,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()); From 981cd25a8ddf688636a0dc4db6a7dd6e86f9e72e Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Wed, 29 Apr 2026 15:26:25 +0100 Subject: [PATCH 05/10] FINERACT-2593: Fix active-loan branch to call helper unconditionally when LoanProduct non-null --- .../delinquency/service/DelinquencyReadPlatformServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 be07d7d9d29..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 @@ -173,7 +173,7 @@ public CollectionData calculateLoanCollectionData(final Long loanId) { // loans collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList); collectionData.setAvailableDisbursementAmount(calculateAvailableDisbursementAmount(loan)); - if (loan.getLoanProduct() != null && loan.getLoanProduct().isAllowApprovedDisbursedAmountsOverApplied()) { + if (loan.getLoanProduct() != null) { collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); } collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan)); From 2139f74df527cbd5575000d7b863214173337a1a Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Thu, 21 May 2026 00:25:46 +0100 Subject: [PATCH 06/10] Remove unnecessary null guards --- .../service/DelinquencyReadPlatformServiceImpl.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 8e878d55919..ce11c7df7cc 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().isAllowApprovedDisbursedAmountsOverApplied()) { collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); } return collectionData; @@ -173,9 +173,8 @@ public CollectionData calculateLoanCollectionData(final Long loanId) { // loans collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList); collectionData.setAvailableDisbursementAmount(calculateAvailableDisbursementAmount(loan)); - if (loan.getLoanProduct() != null) { - collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); - } + collectionData.setAvailableDisbursementAmountWithOverApplied(calculateAvailableDisbursementAmountWithOverApplied(loan)); + collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan)); PossibleNextRepaymentCalculationService possibleNextRepaymentCalculationService = possibleNextRepaymentCalculationServiceDiscovery .getService(loan); From 662daa2df8486a3059fc2fdb5891053d594ceff5 Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Thu, 21 May 2026 14:53:20 +0100 Subject: [PATCH 07/10] FINERACT-2593: Remove duplicate test method in DelinquencyReadPlatformServiceImplTest --- ...elinquencyReadPlatformServiceImplTest.java | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) 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 9066e8c1988..287d24df14a 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 @@ -17,17 +17,16 @@ * under the License. */ package org.apache.fineract.portfolio.delinquency.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; + 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; @@ -37,11 +36,6 @@ 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 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; @@ -325,17 +319,6 @@ private DelinquencyPausePeriod pausePeriod(boolean active, String startDate, Str return new DelinquencyPausePeriod(active, LocalDate.parse(startDate), LocalDate.parse(endDate)); } - @Test - void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { - Loan loan = mock(Loan.class); - when(loan.getLoanProduct()).thenReturn(null); - when(loan.isSubmittedAndPendingApproval()).thenReturn(true); - when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); - - // When product is null, guard prevents calling helper → no exception, returns template - assertThatCode(() -> underTest.calculateLoanCollectionData(1L)).doesNotThrowAnyException(); - } - @Test void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndAvailableDisbursementAmountIsSet() { HashMap businessDates = new HashMap<>(); @@ -379,4 +362,3 @@ void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExcept } } - From 8153a171653fada3060cd48c913df0b4c56492ab Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Thu, 21 May 2026 15:17:54 +0100 Subject: [PATCH 08/10] FINERACT-2593: Remove extra comments --- .../service/DelinquencyReadPlatformServiceImplTest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 287d24df14a..ff2db9108f7 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 @@ -184,8 +184,6 @@ void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExcep 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); @@ -354,7 +352,7 @@ void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExcept assertThat(result).isNotNull(); // calculateAvailableDisbursementAmount = 10000 - 5000 = 5000 assertThat(result.getAvailableDisbursementAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000)); - // LoanProduct is null → over-applied helper skipped → field stays at template default + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); } finally { ThreadLocalContextUtil.reset(); From da07069c2d1a1aee89b03b623677258bd0168b9d Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Thu, 21 May 2026 22:16:23 +0100 Subject: [PATCH 09/10] FINERACT-2593: Fixed failing test case --- .../service/DelinquencyReadPlatformServiceImplTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ff2db9108f7..1c47ccdf484 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 @@ -352,7 +352,7 @@ void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExcept assertThat(result).isNotNull(); // calculateAvailableDisbursementAmount = 10000 - 5000 = 5000 assertThat(result.getAvailableDisbursementAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000)); - + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); } finally { ThreadLocalContextUtil.reset(); From 4b5755b2c092784f5f286b88f1fe204cd2a08122 Mon Sep 17 00:00:00 2001 From: mansimaurya Date: Thu, 21 May 2026 23:47:04 +0100 Subject: [PATCH 10/10] FINERACT-2593: Fixed failing test case --- ...elinquencyReadPlatformServiceImplTest.java | 123 +++++------------- 1 file changed, 35 insertions(+), 88 deletions(-) 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 1c47ccdf484..0a9e1cc0bcc 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 @@ -21,7 +21,6 @@ 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; @@ -32,12 +31,9 @@ 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; @@ -179,53 +175,6 @@ public void testMultiplePausesWithoutResumeActionCurrentBusinessDateBetweenStart ); } - @Test - void givenPendingLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndOverAppliedIsNull() { - Loan loan = mock(Loan.class); - when(loan.getLoanProduct()).thenReturn(null); - when(loan.isSubmittedAndPendingApproval()).thenReturn(true); - 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 @@ -318,45 +267,43 @@ private DelinquencyPausePeriod pausePeriod(boolean active, String startDate, Str } @Test - void givenActiveLoanWithNullProduct_whenCalculateLoanCollectionData_thenNoExceptionAndAvailableDisbursementAmountIsSet() { - 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); + void givenPendingLoanWithOverApplyDisabled_whenCalculateLoanCollectionData_thenOverAppliedAmountNotSet() { + Loan loan = mock(Loan.class); + LoanProduct loanProduct = mock(LoanProduct.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loanProduct.isAllowApprovedDisbursedAmountsOverApplied()).thenReturn(false); + when(loan.isSubmittedAndPendingApproval()).thenReturn(true); + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); - 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); + CollectionData result = underTest.calculateLoanCollectionData(1L); - // calculateAvailableDisbursementAmount() is always called for active loans - when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); - when(loan.getDisbursedAmount()).thenReturn(BigDecimal.valueOf(5000)); - LoanProductRelatedDetail detail = mock(LoanProductRelatedDetail.class); - when(detail.isEnableIncomeCapitalization()).thenReturn(false); - when(loan.getLoanRepaymentScheduleDetail()).thenReturn(detail); - - 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(); - // calculateAvailableDisbursementAmount = 10000 - 5000 = 5000 - assertThat(result.getAvailableDisbursementAmount()).isEqualByComparingTo(BigDecimal.valueOf(5000)); - - assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); - } finally { - ThreadLocalContextUtil.reset(); - } + assertThat(result).isNotNull(); + // over-apply disabled → helper not called → field stays at template default + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + void givenPendingLoanWithOverApplyEnabled_whenCalculateLoanCollectionData_thenOverAppliedAmountIsSet() { + Loan loan = mock(Loan.class); + LoanProduct loanProduct = mock(LoanProduct.class); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loanProduct.isAllowApprovedDisbursedAmountsOverApplied()).thenReturn(true); + when(loanProduct.getOverAppliedCalculationType()).thenReturn("flat"); + when(loanProduct.getOverAppliedNumber()).thenReturn(500); + when(loan.getProposedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getApprovedPrincipal()).thenReturn(BigDecimal.valueOf(10000)); + when(loan.getDisbursedAmount()).thenReturn(BigDecimal.ZERO); + LoanProductRelatedDetail detail = mock(LoanProductRelatedDetail.class); + when(detail.isEnableIncomeCapitalization()).thenReturn(false); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(detail); + when(loan.isSubmittedAndPendingApproval()).thenReturn(true); + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + + CollectionData result = underTest.calculateLoanCollectionData(1L); + + assertThat(result).isNotNull(); + // flat over-apply: 10000 + 500 = 10500, minus 0 disbursed = 10500 + assertThat(result.getAvailableDisbursementAmountWithOverApplied()).isEqualByComparingTo(BigDecimal.valueOf(10500)); } }