|
20 | 20 |
|
21 | 21 | import static org.junit.jupiter.api.Assertions.assertEquals; |
22 | 22 | import static org.junit.jupiter.api.Assertions.assertThrows; |
| 23 | +import static org.mockito.ArgumentMatchers.any; |
23 | 24 | import static org.mockito.ArgumentMatchers.anyLong; |
24 | 25 | import static org.mockito.Mockito.mock; |
| 26 | +import static org.mockito.Mockito.times; |
| 27 | +import static org.mockito.Mockito.verify; |
25 | 28 | import static org.mockito.Mockito.when; |
26 | 29 |
|
27 | 30 | import java.math.BigDecimal; |
| 31 | +import java.time.LocalDate; |
28 | 32 | import java.util.HashMap; |
29 | 33 | import java.util.List; |
| 34 | +import java.util.Locale; |
30 | 35 | import java.util.Map; |
31 | 36 | import java.util.Set; |
32 | 37 | import org.apache.fineract.commands.service.CommandProcessingService; |
33 | 38 | import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; |
| 39 | +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; |
34 | 40 | import org.apache.fineract.infrastructure.core.api.JsonCommand; |
35 | 41 | import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; |
36 | 42 | import org.apache.fineract.infrastructure.core.domain.ExternalId; |
|
42 | 48 | import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; |
43 | 49 | import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; |
44 | 50 | import org.apache.fineract.organisation.monetary.data.CurrencyData; |
| 51 | +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; |
45 | 52 | import org.apache.fineract.organisation.monetary.domain.Money; |
46 | 53 | 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; |
47 | 58 | import org.apache.fineract.portfolio.client.domain.Client; |
48 | 59 | import org.apache.fineract.portfolio.loanaccount.domain.Loan; |
| 60 | +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; |
49 | 61 | import org.apache.fineract.portfolio.loanaccount.domain.LoanBuilder; |
50 | 62 | 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; |
51 | 65 | import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository; |
52 | 66 | import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; |
53 | 67 | import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; |
|
58 | 72 | import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; |
59 | 73 | import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; |
60 | 74 | import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; |
| 75 | +import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; |
61 | 76 | import org.apache.fineract.useradministration.domain.AppUser; |
62 | 77 | import org.junit.jupiter.api.BeforeEach; |
63 | 78 | import org.junit.jupiter.api.Test; |
64 | 79 | import org.junit.jupiter.api.extension.ExtendWith; |
65 | 80 | import org.mockito.InjectMocks; |
66 | 81 | import org.mockito.Mock; |
67 | 82 | import org.mockito.junit.jupiter.MockitoExtension; |
| 83 | +import org.mockito.junit.jupiter.MockitoSettings; |
| 84 | +import org.mockito.quality.Strictness; |
68 | 85 | import org.springframework.context.ApplicationContext; |
69 | 86 |
|
70 | 87 | @ExtendWith(MockitoExtension.class) |
@@ -112,6 +129,39 @@ public class LoanWritePlatformServiceJpaRepositoryImplTest { |
112 | 129 | @Mock |
113 | 130 | private LoanAccrualTransactionBusinessEventService loanAccrualTransactionBusinessEventService; |
114 | 131 |
|
| 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 | + |
115 | 165 | @InjectMocks |
116 | 166 | private LoanWritePlatformServiceJpaRepositoryImpl loanWritePlatformService; |
117 | 167 |
|
@@ -259,4 +309,172 @@ public void chargeOff_forReversedTransaction_shouldRun() { |
259 | 309 |
|
260 | 310 | assertEquals(1L, result.getClientId()); |
261 | 311 | } |
| 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 | + } |
262 | 480 | } |
0 commit comments