Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -58,13 +72,16 @@
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;
import org.junit.jupiter.api.extension.ExtendWith;
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)
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
Loading