Skip to content

Commit fac952f

Browse files
authored
Merge pull request #5710
FINERACT-2566: Refresh loan summary after account-transfer disbursement charge collection
2 parents 98bc927 + b1253cb commit fac952f

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,14 @@ public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand
492492
isRegularTransaction, isExceptionForBalanceCheck);
493493
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
494494
}
495+
if (!disBuLoanCharges.isEmpty()) {
496+
// transferFunds marks each charge as paid and creates REPAYMENT_AT_DISBURSEMENT loan transactions,
497+
// but it does not recompute the LoanSummary fields (feeChargesPaid, feeChargesOutstanding,
498+
// totalOutstanding, etc.). Without this, those fields remain stale from the saveAndFlush above,
499+
// causing the loan balance to appear higher than what the client actually owes.
500+
loanBalanceService.updateLoanSummaryDerivedFields(loan);
501+
saveAndFlushLoanWithDataIntegrityViolationChecks(loan);
502+
}
495503
updateRecurringCalendarDatesForInterestRecalculation(loan);
496504
loanAccrualsProcessingService.processAccrualsOnInterestRecalculation(loan, loan.isInterestBearingAndInterestRecalculationEnabled(),
497505
true);

fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,23 @@
2020

2121
import static org.junit.jupiter.api.Assertions.assertEquals;
2222
import static org.junit.jupiter.api.Assertions.assertThrows;
23+
import static org.mockito.ArgumentMatchers.any;
2324
import static org.mockito.ArgumentMatchers.anyLong;
2425
import static org.mockito.Mockito.mock;
26+
import static org.mockito.Mockito.times;
27+
import static org.mockito.Mockito.verify;
2528
import static org.mockito.Mockito.when;
2629

2730
import java.math.BigDecimal;
31+
import java.time.LocalDate;
2832
import java.util.HashMap;
2933
import java.util.List;
34+
import java.util.Locale;
3035
import java.util.Map;
3136
import java.util.Set;
3237
import org.apache.fineract.commands.service.CommandProcessingService;
3338
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
39+
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
3440
import org.apache.fineract.infrastructure.core.api.JsonCommand;
3541
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
3642
import org.apache.fineract.infrastructure.core.domain.ExternalId;
@@ -42,12 +48,20 @@
4248
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
4349
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
4450
import org.apache.fineract.organisation.monetary.data.CurrencyData;
51+
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
4552
import org.apache.fineract.organisation.monetary.domain.Money;
4653
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
54+
import org.apache.fineract.portfolio.account.data.PortfolioAccountData;
55+
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
56+
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
57+
import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode;
4758
import org.apache.fineract.portfolio.client.domain.Client;
4859
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
60+
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
4961
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuilder;
5062
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
63+
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
64+
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
5165
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository;
5266
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
5367
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
@@ -58,13 +72,16 @@
5872
import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator;
5973
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
6074
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
75+
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
6176
import org.apache.fineract.useradministration.domain.AppUser;
6277
import org.junit.jupiter.api.BeforeEach;
6378
import org.junit.jupiter.api.Test;
6479
import org.junit.jupiter.api.extension.ExtendWith;
6580
import org.mockito.InjectMocks;
6681
import org.mockito.Mock;
6782
import org.mockito.junit.jupiter.MockitoExtension;
83+
import org.mockito.junit.jupiter.MockitoSettings;
84+
import org.mockito.quality.Strictness;
6885
import org.springframework.context.ApplicationContext;
6986

7087
@ExtendWith(MockitoExtension.class)
@@ -112,6 +129,39 @@ public class LoanWritePlatformServiceJpaRepositoryImplTest {
112129
@Mock
113130
private LoanAccrualTransactionBusinessEventService loanAccrualTransactionBusinessEventService;
114131

132+
@Mock
133+
private LoanBalanceService loanBalanceService;
134+
135+
@Mock
136+
private AccountTransfersWritePlatformService accountTransfersWritePlatformService;
137+
138+
@Mock
139+
private AccountAssociationsReadPlatformService accountAssociationsReadPlatformService;
140+
141+
@Mock
142+
private LoanDisbursementService loanDisbursementService;
143+
144+
@Mock
145+
private ConfigurationDomainService configurationDomainService;
146+
147+
@Mock
148+
private LoanUtilService loanUtilService;
149+
150+
@Mock
151+
private LoanAccountDomainService loanAccountDomainService;
152+
153+
@Mock
154+
private LoanAccrualsProcessingService loanAccrualsProcessingService;
155+
156+
@Mock
157+
private PaymentDetailWritePlatformService paymentDetailWritePlatformService;
158+
159+
@Mock
160+
private LoanLifecycleStateMachine loanLifecycleStateMachine;
161+
162+
@Mock
163+
private LoanScheduleService loanScheduleService;
164+
115165
@InjectMocks
116166
private LoanWritePlatformServiceJpaRepositoryImpl loanWritePlatformService;
117167

@@ -259,4 +309,172 @@ public void chargeOff_forReversedTransaction_shouldRun() {
259309

260310
assertEquals(1L, result.getClientId());
261311
}
312+
313+
@Test
314+
@MockitoSettings(strictness = Strictness.LENIENT)
315+
public void disburseLoan_withAccountTransferDisbursementCharge_shouldRefreshLoanSummary() {
316+
setupMoneyHelper();
317+
318+
final LocalDate disbursementDate = DateUtils.parseLocalDate("2025-05-20");
319+
final MonetaryCurrency currency = new MonetaryCurrency("KES", 2, null);
320+
321+
// Setup loan product
322+
LoanProductRelatedDetail loanProductDetail = mock(LoanProductRelatedDetail.class);
323+
LoanProduct loanProduct = mock(LoanProduct.class);
324+
when(loanProduct.getLoanProductRelatedDetail()).thenReturn(loanProductDetail);
325+
when(loanProduct.isMultiDisburseLoan()).thenReturn(false);
326+
when(loanProduct.isDisallowExpectedDisbursements()).thenReturn(false);
327+
when(loanProduct.getId()).thenReturn(1L);
328+
when(loanProduct.isIncludeInBorrowerCycle()).thenReturn(false);
329+
330+
// Setup disbursement charge with ACCOUNT_TRANSFER payment mode.
331+
// Charges with this mode are collected from the linked savings account via transferFunds().
332+
// After that loop, the LoanSummary must be refreshed — that's the bug fix under test.
333+
LoanCharge disbursementCharge = mock(LoanCharge.class);
334+
when(disbursementCharge.isDueAtDisbursement()).thenReturn(true);
335+
when(disbursementCharge.getChargePaymentMode()).thenReturn(ChargePaymentMode.ACCOUNT_TRANSFER);
336+
when(disbursementCharge.isChargePending()).thenReturn(true);
337+
when(disbursementCharge.amountOutstanding()).thenReturn(BigDecimal.valueOf(500));
338+
when(disbursementCharge.getId()).thenReturn(100L);
339+
when(disbursementCharge.isActive()).thenReturn(true);
340+
341+
// Setup repayment schedule installment
342+
LoanRepaymentScheduleInstallment installment = mock(LoanRepaymentScheduleInstallment.class);
343+
when(installment.getDueDate()).thenReturn(disbursementDate.plusMonths(1));
344+
345+
// Setup summary
346+
LoanSummary summary = mock(LoanSummary.class);
347+
when(summary.getTotalInterestCharged()).thenReturn(BigDecimal.ZERO);
348+
349+
// Setup loan as mock for full control over method return values
350+
loan = mock(Loan.class);
351+
when(loan.getId()).thenReturn(LOAN_ID);
352+
when(loan.loanProduct()).thenReturn(loanProduct);
353+
when(loan.getLoanProduct()).thenReturn(loanProduct);
354+
when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductDetail);
355+
when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductDetail);
356+
when(loan.getActiveCharges()).thenReturn(Set.of(disbursementCharge));
357+
when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(installment));
358+
when(loan.fetchRepaymentScheduleInstallment(1)).thenReturn(installment);
359+
when(loan.isGroupLoan()).thenReturn(false);
360+
when(loan.getClientId()).thenReturn(1L);
361+
when(loan.getStatus()).thenReturn(LoanStatus.APPROVED);
362+
when(loan.getLoanStatus()).thenReturn(LoanStatus.ACTIVE);
363+
when(loan.isMultiDisburmentLoan()).thenReturn(false);
364+
when(loan.isTopup()).thenReturn(false);
365+
when(loan.getPrincipal()).thenReturn(Money.of(currency, BigDecimal.valueOf(20000)));
366+
when(loan.getCurrency()).thenReturn(currency);
367+
when(loan.getSummary()).thenReturn(summary);
368+
when(loan.getNextPossibleRepaymentDateForRescheduling()).thenReturn(disbursementDate.plusMonths(1));
369+
when(loan.deriveSumTotalOfChargesDueAtDisbursement()).thenReturn(BigDecimal.valueOf(500));
370+
when(loan.shouldCreateStandingInstructionAtDisbursement()).thenReturn(false);
371+
when(loan.getIsFloatingInterestRate()).thenReturn(false);
372+
when(loan.getExternalId()).thenReturn(ExternalId.empty());
373+
374+
// Setup command
375+
command = mock(JsonCommand.class);
376+
when(command.localDateValueOfParameterNamed("actualDisbursementDate")).thenReturn(disbursementDate);
377+
when(command.extractLocale()).thenReturn(Locale.ENGLISH);
378+
when(command.dateFormat()).thenReturn("dd MMMM yyyy");
379+
380+
// Setup service mocks
381+
when(loanAssembler.assembleFrom(LOAN_ID)).thenReturn(loan);
382+
when(loanLifecycleStateMachine.dryTransition(any(), any())).thenReturn(LoanStatus.ACTIVE);
383+
when(loanDisbursementService.adjustDisburseAmount(any(), any(), any())).thenReturn(Money.of(currency, BigDecimal.valueOf(20000)));
384+
when(externalIdFactory.createFromCommand(any(), any())).thenReturn(ExternalId.empty());
385+
386+
PortfolioAccountData linkedAccount = PortfolioAccountData.lookup(2L, "SA001");
387+
when(accountAssociationsReadPlatformService.retriveLoanLinkedAssociation(LOAN_ID)).thenReturn(linkedAccount);
388+
when(loanRepositoryWrapper.getClientOrJLGLoansDisbursedAfter(any(), anyLong())).thenReturn(List.of());
389+
when(loanRepositoryWrapper.saveAndFlush(any(Loan.class))).thenReturn(loan);
390+
391+
// ACT
392+
loanWritePlatformService.disburseLoan(LOAN_ID, command, true, false);
393+
394+
// ASSERT: The private disburseLoan() calls updateLoanSummaryDerivedFields once.
395+
// Our fix adds a second call after the account-transfer charge loop.
396+
verify(loanBalanceService, times(2)).updateLoanSummaryDerivedFields(loan);
397+
}
398+
399+
@Test
400+
@MockitoSettings(strictness = Strictness.LENIENT)
401+
public void disburseLoan_withoutAccountTransferDisbursementCharge_shouldNotRefreshLoanSummaryExtraTime() {
402+
setupMoneyHelper();
403+
404+
final LocalDate disbursementDate = DateUtils.parseLocalDate("2025-05-20");
405+
final MonetaryCurrency currency = new MonetaryCurrency("KES", 2, null);
406+
407+
// Setup loan product
408+
LoanProductRelatedDetail loanProductDetail = mock(LoanProductRelatedDetail.class);
409+
LoanProduct loanProduct = mock(LoanProduct.class);
410+
when(loanProduct.getLoanProductRelatedDetail()).thenReturn(loanProductDetail);
411+
when(loanProduct.isMultiDisburseLoan()).thenReturn(false);
412+
when(loanProduct.isDisallowExpectedDisbursements()).thenReturn(false);
413+
when(loanProduct.getId()).thenReturn(1L);
414+
when(loanProduct.isIncludeInBorrowerCycle()).thenReturn(false);
415+
416+
// A regular (non-ACCOUNT_TRANSFER) disbursement charge — should NOT trigger the extra summary refresh.
417+
LoanCharge disbursementCharge = mock(LoanCharge.class);
418+
when(disbursementCharge.isDueAtDisbursement()).thenReturn(true);
419+
when(disbursementCharge.getChargePaymentMode()).thenReturn(ChargePaymentMode.REGULAR);
420+
when(disbursementCharge.isChargePending()).thenReturn(true);
421+
when(disbursementCharge.isActive()).thenReturn(true);
422+
423+
// Setup repayment schedule installment
424+
LoanRepaymentScheduleInstallment installment = mock(LoanRepaymentScheduleInstallment.class);
425+
when(installment.getDueDate()).thenReturn(disbursementDate.plusMonths(1));
426+
427+
// Setup summary
428+
LoanSummary summary = mock(LoanSummary.class);
429+
when(summary.getTotalInterestCharged()).thenReturn(BigDecimal.ZERO);
430+
431+
// Setup loan
432+
loan = mock(Loan.class);
433+
when(loan.getId()).thenReturn(LOAN_ID);
434+
when(loan.loanProduct()).thenReturn(loanProduct);
435+
when(loan.getLoanProduct()).thenReturn(loanProduct);
436+
when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductDetail);
437+
when(loan.getLoanRepaymentScheduleDetail()).thenReturn(loanProductDetail);
438+
when(loan.getActiveCharges()).thenReturn(Set.of(disbursementCharge));
439+
when(loan.getRepaymentScheduleInstallments()).thenReturn(List.of(installment));
440+
when(loan.fetchRepaymentScheduleInstallment(1)).thenReturn(installment);
441+
when(loan.isGroupLoan()).thenReturn(false);
442+
when(loan.getClientId()).thenReturn(1L);
443+
when(loan.getStatus()).thenReturn(LoanStatus.APPROVED);
444+
when(loan.getLoanStatus()).thenReturn(LoanStatus.ACTIVE);
445+
when(loan.isMultiDisburmentLoan()).thenReturn(false);
446+
when(loan.isTopup()).thenReturn(false);
447+
when(loan.getPrincipal()).thenReturn(Money.of(currency, BigDecimal.valueOf(20000)));
448+
when(loan.getCurrency()).thenReturn(currency);
449+
when(loan.getSummary()).thenReturn(summary);
450+
when(loan.getNextPossibleRepaymentDateForRescheduling()).thenReturn(disbursementDate.plusMonths(1));
451+
when(loan.deriveSumTotalOfChargesDueAtDisbursement()).thenReturn(BigDecimal.ZERO);
452+
when(loan.shouldCreateStandingInstructionAtDisbursement()).thenReturn(false);
453+
when(loan.getIsFloatingInterestRate()).thenReturn(false);
454+
when(loan.getExternalId()).thenReturn(ExternalId.empty());
455+
456+
// Setup command
457+
command = mock(JsonCommand.class);
458+
when(command.localDateValueOfParameterNamed("actualDisbursementDate")).thenReturn(disbursementDate);
459+
when(command.extractLocale()).thenReturn(Locale.ENGLISH);
460+
when(command.dateFormat()).thenReturn("dd MMMM yyyy");
461+
462+
// Setup service mocks
463+
when(loanAssembler.assembleFrom(LOAN_ID)).thenReturn(loan);
464+
when(loanLifecycleStateMachine.dryTransition(any(), any())).thenReturn(LoanStatus.ACTIVE);
465+
when(loanDisbursementService.adjustDisburseAmount(any(), any(), any())).thenReturn(Money.of(currency, BigDecimal.valueOf(20000)));
466+
when(externalIdFactory.createFromCommand(any(), any())).thenReturn(ExternalId.empty());
467+
PortfolioAccountData linkedAccount = PortfolioAccountData.lookup(2L, "SA001");
468+
when(accountAssociationsReadPlatformService.retriveLoanLinkedAssociation(LOAN_ID)).thenReturn(linkedAccount);
469+
when(loanRepositoryWrapper.getClientOrJLGLoansDisbursedAfter(any(), anyLong())).thenReturn(List.of());
470+
when(loanRepositoryWrapper.saveAndFlush(any(Loan.class))).thenReturn(loan);
471+
472+
// ACT
473+
loanWritePlatformService.disburseLoan(LOAN_ID, command, true, false);
474+
475+
// ASSERT: With no ACCOUNT_TRANSFER disbursement charges, disBuLoanCharges is empty and
476+
// the fix block is skipped. updateLoanSummaryDerivedFields is called exactly once
477+
// (the standard call inside the private disburseLoan helper).
478+
verify(loanBalanceService, times(1)).updateLoanSummaryDerivedFields(loan);
479+
}
262480
}

0 commit comments

Comments
 (0)