diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 83d6cc26a51..71063731c8c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -492,6 +492,14 @@ public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand isRegularTransaction, isExceptionForBalanceCheck); this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); } + if (!disBuLoanCharges.isEmpty()) { + // transferFunds marks each charge as paid and creates REPAYMENT_AT_DISBURSEMENT loan transactions, + // but it does not recompute the LoanSummary fields (feeChargesPaid, feeChargesOutstanding, + // totalOutstanding, etc.). Without this, those fields remain stale from the saveAndFlush above, + // causing the loan balance to appear higher than what the client actually owes. + loanBalanceService.updateLoanSummaryDerivedFields(loan); + saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + } updateRecurringCalendarDatesForInterestRecalculation(loan); loanAccrualsProcessingService.processAccrualsOnInterestRecalculation(loan, loan.isInterestBearingAndInterestRecalculationEnabled(), true); diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java index b3c3ab213d0..4866c2cb65b 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java @@ -20,17 +20,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.math.BigDecimal; +import java.time.LocalDate; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.apache.fineract.commands.service.CommandProcessingService; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.ExternalId; @@ -42,12 +48,20 @@ import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.account.data.PortfolioAccountData; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; import org.apache.fineract.portfolio.loanaccount.domain.LoanBuilder; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; @@ -58,6 +72,7 @@ import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; +import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.useradministration.domain.AppUser; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,6 +80,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import org.springframework.context.ApplicationContext; @ExtendWith(MockitoExtension.class) @@ -112,6 +129,39 @@ public class LoanWritePlatformServiceJpaRepositoryImplTest { @Mock private LoanAccrualTransactionBusinessEventService loanAccrualTransactionBusinessEventService; + @Mock + private LoanBalanceService loanBalanceService; + + @Mock + private AccountTransfersWritePlatformService accountTransfersWritePlatformService; + + @Mock + private AccountAssociationsReadPlatformService accountAssociationsReadPlatformService; + + @Mock + private LoanDisbursementService loanDisbursementService; + + @Mock + private ConfigurationDomainService configurationDomainService; + + @Mock + private LoanUtilService loanUtilService; + + @Mock + private LoanAccountDomainService loanAccountDomainService; + + @Mock + private LoanAccrualsProcessingService loanAccrualsProcessingService; + + @Mock + private PaymentDetailWritePlatformService paymentDetailWritePlatformService; + + @Mock + private LoanLifecycleStateMachine loanLifecycleStateMachine; + + @Mock + private LoanScheduleService loanScheduleService; + @InjectMocks private LoanWritePlatformServiceJpaRepositoryImpl loanWritePlatformService; @@ -259,4 +309,172 @@ public void chargeOff_forReversedTransaction_shouldRun() { assertEquals(1L, result.getClientId()); } + + @Test + @MockitoSettings(strictness = Strictness.LENIENT) + public void disburseLoan_withAccountTransferDisbursementCharge_shouldRefreshLoanSummary() { + setupMoneyHelper(); + + final LocalDate disbursementDate = DateUtils.parseLocalDate("2025-05-20"); + final MonetaryCurrency currency = new MonetaryCurrency("KES", 2, null); + + // Setup loan product + LoanProductRelatedDetail loanProductDetail = mock(LoanProductRelatedDetail.class); + LoanProduct loanProduct = mock(LoanProduct.class); + when(loanProduct.getLoanProductRelatedDetail()).thenReturn(loanProductDetail); + when(loanProduct.isMultiDisburseLoan()).thenReturn(false); + when(loanProduct.isDisallowExpectedDisbursements()).thenReturn(false); + when(loanProduct.getId()).thenReturn(1L); + when(loanProduct.isIncludeInBorrowerCycle()).thenReturn(false); + + // Setup disbursement charge with ACCOUNT_TRANSFER payment mode. + // Charges with this mode are collected from the linked savings account via transferFunds(). + // After that loop, the LoanSummary must be refreshed — that's the bug fix under test. + LoanCharge disbursementCharge = mock(LoanCharge.class); + when(disbursementCharge.isDueAtDisbursement()).thenReturn(true); + when(disbursementCharge.getChargePaymentMode()).thenReturn(ChargePaymentMode.ACCOUNT_TRANSFER); + when(disbursementCharge.isChargePending()).thenReturn(true); + when(disbursementCharge.amountOutstanding()).thenReturn(BigDecimal.valueOf(500)); + when(disbursementCharge.getId()).thenReturn(100L); + when(disbursementCharge.isActive()).thenReturn(true); + + // Setup repayment schedule installment + LoanRepaymentScheduleInstallment installment = mock(LoanRepaymentScheduleInstallment.class); + when(installment.getDueDate()).thenReturn(disbursementDate.plusMonths(1)); + + // Setup summary + LoanSummary summary = mock(LoanSummary.class); + when(summary.getTotalInterestCharged()).thenReturn(BigDecimal.ZERO); + + // Setup loan as mock for full control over method return values + loan = mock(Loan.class); + when(loan.getId()).thenReturn(LOAN_ID); + when(loan.loanProduct()).thenReturn(loanProduct); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductDetail); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductDetail); + when(loan.getActiveCharges()).thenReturn(Set.of(disbursementCharge)); + when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(installment)); + when(loan.fetchRepaymentScheduleInstallment(1)).thenReturn(installment); + when(loan.isGroupLoan()).thenReturn(false); + when(loan.getClientId()).thenReturn(1L); + when(loan.getStatus()).thenReturn(LoanStatus.APPROVED); + when(loan.getLoanStatus()).thenReturn(LoanStatus.ACTIVE); + when(loan.isMultiDisburmentLoan()).thenReturn(false); + when(loan.isTopup()).thenReturn(false); + when(loan.getPrincipal()).thenReturn(Money.of(currency, BigDecimal.valueOf(20000))); + when(loan.getCurrency()).thenReturn(currency); + when(loan.getSummary()).thenReturn(summary); + when(loan.getNextPossibleRepaymentDateForRescheduling()).thenReturn(disbursementDate.plusMonths(1)); + when(loan.deriveSumTotalOfChargesDueAtDisbursement()).thenReturn(BigDecimal.valueOf(500)); + when(loan.shouldCreateStandingInstructionAtDisbursement()).thenReturn(false); + when(loan.getIsFloatingInterestRate()).thenReturn(false); + when(loan.getExternalId()).thenReturn(ExternalId.empty()); + + // Setup command + command = mock(JsonCommand.class); + when(command.localDateValueOfParameterNamed("actualDisbursementDate")).thenReturn(disbursementDate); + when(command.extractLocale()).thenReturn(Locale.ENGLISH); + when(command.dateFormat()).thenReturn("dd MMMM yyyy"); + + // Setup service mocks + when(loanAssembler.assembleFrom(LOAN_ID)).thenReturn(loan); + when(loanLifecycleStateMachine.dryTransition(any(), any())).thenReturn(LoanStatus.ACTIVE); + when(loanDisbursementService.adjustDisburseAmount(any(), any(), any())).thenReturn(Money.of(currency, BigDecimal.valueOf(20000))); + when(externalIdFactory.createFromCommand(any(), any())).thenReturn(ExternalId.empty()); + + PortfolioAccountData linkedAccount = PortfolioAccountData.lookup(2L, "SA001"); + when(accountAssociationsReadPlatformService.retriveLoanLinkedAssociation(LOAN_ID)).thenReturn(linkedAccount); + when(loanRepositoryWrapper.getClientOrJLGLoansDisbursedAfter(any(), anyLong())).thenReturn(List.of()); + when(loanRepositoryWrapper.saveAndFlush(any(Loan.class))).thenReturn(loan); + + // ACT + loanWritePlatformService.disburseLoan(LOAN_ID, command, true, false); + + // ASSERT: The private disburseLoan() calls updateLoanSummaryDerivedFields once. + // Our fix adds a second call after the account-transfer charge loop. + verify(loanBalanceService, times(2)).updateLoanSummaryDerivedFields(loan); + } + + @Test + @MockitoSettings(strictness = Strictness.LENIENT) + public void disburseLoan_withoutAccountTransferDisbursementCharge_shouldNotRefreshLoanSummaryExtraTime() { + setupMoneyHelper(); + + final LocalDate disbursementDate = DateUtils.parseLocalDate("2025-05-20"); + final MonetaryCurrency currency = new MonetaryCurrency("KES", 2, null); + + // Setup loan product + LoanProductRelatedDetail loanProductDetail = mock(LoanProductRelatedDetail.class); + LoanProduct loanProduct = mock(LoanProduct.class); + when(loanProduct.getLoanProductRelatedDetail()).thenReturn(loanProductDetail); + when(loanProduct.isMultiDisburseLoan()).thenReturn(false); + when(loanProduct.isDisallowExpectedDisbursements()).thenReturn(false); + when(loanProduct.getId()).thenReturn(1L); + when(loanProduct.isIncludeInBorrowerCycle()).thenReturn(false); + + // A regular (non-ACCOUNT_TRANSFER) disbursement charge — should NOT trigger the extra summary refresh. + LoanCharge disbursementCharge = mock(LoanCharge.class); + when(disbursementCharge.isDueAtDisbursement()).thenReturn(true); + when(disbursementCharge.getChargePaymentMode()).thenReturn(ChargePaymentMode.REGULAR); + when(disbursementCharge.isChargePending()).thenReturn(true); + when(disbursementCharge.isActive()).thenReturn(true); + + // Setup repayment schedule installment + LoanRepaymentScheduleInstallment installment = mock(LoanRepaymentScheduleInstallment.class); + when(installment.getDueDate()).thenReturn(disbursementDate.plusMonths(1)); + + // Setup summary + LoanSummary summary = mock(LoanSummary.class); + when(summary.getTotalInterestCharged()).thenReturn(BigDecimal.ZERO); + + // Setup loan + loan = mock(Loan.class); + when(loan.getId()).thenReturn(LOAN_ID); + when(loan.loanProduct()).thenReturn(loanProduct); + when(loan.getLoanProduct()).thenReturn(loanProduct); + when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductDetail); + when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductDetail); + when(loan.getActiveCharges()).thenReturn(Set.of(disbursementCharge)); + when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(installment)); + when(loan.fetchRepaymentScheduleInstallment(1)).thenReturn(installment); + when(loan.isGroupLoan()).thenReturn(false); + when(loan.getClientId()).thenReturn(1L); + when(loan.getStatus()).thenReturn(LoanStatus.APPROVED); + when(loan.getLoanStatus()).thenReturn(LoanStatus.ACTIVE); + when(loan.isMultiDisburmentLoan()).thenReturn(false); + when(loan.isTopup()).thenReturn(false); + when(loan.getPrincipal()).thenReturn(Money.of(currency, BigDecimal.valueOf(20000))); + when(loan.getCurrency()).thenReturn(currency); + when(loan.getSummary()).thenReturn(summary); + when(loan.getNextPossibleRepaymentDateForRescheduling()).thenReturn(disbursementDate.plusMonths(1)); + when(loan.deriveSumTotalOfChargesDueAtDisbursement()).thenReturn(BigDecimal.ZERO); + when(loan.shouldCreateStandingInstructionAtDisbursement()).thenReturn(false); + when(loan.getIsFloatingInterestRate()).thenReturn(false); + when(loan.getExternalId()).thenReturn(ExternalId.empty()); + + // Setup command + command = mock(JsonCommand.class); + when(command.localDateValueOfParameterNamed("actualDisbursementDate")).thenReturn(disbursementDate); + when(command.extractLocale()).thenReturn(Locale.ENGLISH); + when(command.dateFormat()).thenReturn("dd MMMM yyyy"); + + // Setup service mocks + when(loanAssembler.assembleFrom(LOAN_ID)).thenReturn(loan); + when(loanLifecycleStateMachine.dryTransition(any(), any())).thenReturn(LoanStatus.ACTIVE); + when(loanDisbursementService.adjustDisburseAmount(any(), any(), any())).thenReturn(Money.of(currency, BigDecimal.valueOf(20000))); + when(externalIdFactory.createFromCommand(any(), any())).thenReturn(ExternalId.empty()); + PortfolioAccountData linkedAccount = PortfolioAccountData.lookup(2L, "SA001"); + when(accountAssociationsReadPlatformService.retriveLoanLinkedAssociation(LOAN_ID)).thenReturn(linkedAccount); + when(loanRepositoryWrapper.getClientOrJLGLoansDisbursedAfter(any(), anyLong())).thenReturn(List.of()); + when(loanRepositoryWrapper.saveAndFlush(any(Loan.class))).thenReturn(loan); + + // ACT + loanWritePlatformService.disburseLoan(LOAN_ID, command, true, false); + + // ASSERT: With no ACCOUNT_TRANSFER disbursement charges, disBuLoanCharges is empty and + // the fix block is skipped. updateLoanSummaryDerivedFields is called exactly once + // (the standard call inside the private disburseLoan helper). + verify(loanBalanceService, times(1)).updateLoanSummaryDerivedFields(loan); + } }