FINERACT-2317: Add support for modifying loan approved amounts with validation and history tracking

This commit is contained in:
mark.vituska
2025-07-21 17:13:51 +02:00
committed by Adam Saghy
parent c84ba089f0
commit 6d65f07924
30 changed files with 1348 additions and 12 deletions
@@ -67,6 +67,7 @@ import org.apache.fineract.client.models.GetLoansLoanIdStatus;
import org.apache.fineract.client.models.GetLoansLoanIdTransactions;
import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse;
import org.apache.fineract.client.models.JournalEntryTransactionItem;
import org.apache.fineract.client.models.LoanApprovedAmountHistoryData;
import org.apache.fineract.client.models.LoanPointInTimeData;
import org.apache.fineract.client.models.PaymentAllocationOrder;
import org.apache.fineract.client.models.PostChargesResponse;
@@ -81,6 +82,8 @@ import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionI
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
import org.apache.fineract.client.models.PutLoansApprovedAmountRequest;
import org.apache.fineract.client.models.PutLoansApprovedAmountResponse;
import org.apache.fineract.client.models.PutLoansLoanIdResponse;
import org.apache.fineract.client.models.RetrieveLoansPointInTimeRequest;
import org.apache.fineract.client.util.CallFailedRuntimeException;
@@ -777,6 +780,15 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest {
return Calls.ok(fineractClient().loansPointInTimeApi.retrieveLoansPointInTime(request));
}
protected PutLoansApprovedAmountResponse modifyLoanApprovedAmount(Long loanId, BigDecimal approvedAmount) {
PutLoansApprovedAmountRequest request = new PutLoansApprovedAmountRequest().amount(approvedAmount).locale("en");
return Calls.ok(fineractClient().loans.modifyLoanApprovedAmount(loanId, request));
}
protected List<LoanApprovedAmountHistoryData> getLoanApprovedAmountHistory(Long loanId) {
return Calls.ok(fineractClient().loans.getLoanApprovedAmountHistory(loanId));
}
protected void verifyOutstanding(LoanPointInTimeData loan, OutstandingAmounts outstanding) {
assertThat(BigDecimal.valueOf(outstanding.principalOutstanding))
.isEqualByComparingTo(loan.getPrincipal().getPrincipalOutstanding());
@@ -0,0 +1,481 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.integrationtests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Objects;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdTransactions;
import org.apache.fineract.client.models.LoanApprovedAmountHistoryData;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansDisbursementData;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.client.models.PutLoansApprovedAmountResponse;
import org.apache.fineract.client.util.CallFailedRuntimeException;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.externalevents.LoanBusinessEvent;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class LoanModifyApprovedAmountTests extends BaseLoanIntegrationTest {
@Test
public void testValidLoanApprovedAmountModification() {
BigDecimal sixHundred = BigDecimal.valueOf(600.0);
BigDecimal thousand = BigDecimal.valueOf(1000.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024");
PutLoansApprovedAmountResponse putLoansApprovedAmountResponse = modifyLoanApprovedAmount(loanId, sixHundred);
Assertions.assertEquals(loanId, putLoansApprovedAmountResponse.getResourceId());
Assertions.assertNotNull(putLoansApprovedAmountResponse.getChanges());
Assertions.assertNotNull(putLoansApprovedAmountResponse.getChanges().getNewApprovedAmount());
Assertions.assertNotNull(putLoansApprovedAmountResponse.getChanges().getOldApprovedAmount());
Assertions.assertEquals(sixHundred,
putLoansApprovedAmountResponse.getChanges().getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(thousand,
putLoansApprovedAmountResponse.getChanges().getOldApprovedAmount().setScale(1, RoundingMode.HALF_UP));
});
}
@Test
public void testLoanApprovedAmountModificationEvent() {
externalEventHelper.enableBusinessEvent("LoanApprovedAmountChangedBusinessEvent");
BigDecimal sixHundred = BigDecimal.valueOf(600.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024");
deleteAllExternalEvents();
modifyLoanApprovedAmount(loanId, sixHundred);
verifyBusinessEvents(new LoanBusinessEvent("LoanApprovedAmountChangedBusinessEvent", "01 January 2024", 300, 100.0, 100.0));
});
}
@Test
public void testValidLoanApprovedAmountModificationInvalidRequest() {
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, null));
assertEquals(400, exception.getResponse().code());
assertTrue(exception.getMessage().contains("validation.msg.loan.approved.amount.amount.cannot.be.blank"));
});
}
@Test
public void testValidLoanApprovedAmountModificationInvalidLoanStatus() {
BigDecimal sixHundred = BigDecimal.valueOf(600.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applyLP2ProgressiveLoanRequest(client.getClientId(),
loanProductsResponse.getResourceId(), "1 January 2024", 1000.0, 10.0, 4, null));
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(postLoansResponse.getResourceId(), sixHundred));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage()
.contains("validation.msg.loan.approved.amount.loan.status.not.valid.for.approved.amount.modification"));
});
}
@Test
public void testModifyLoanApprovedAmountTooHigh() {
BigDecimal twoThousand = BigDecimal.valueOf(2000.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, twoThousand));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage()
.contains("validation.msg.loan.approved.amount.amount.can't.be.greater.than.maximum.applied.loan.amount.calculation"));
});
}
@Test
public void testModifyLoanApprovedAmountHigherButInRange() {
BigDecimal thousand = BigDecimal.valueOf(1000.0);
BigDecimal fifteenHundred = BigDecimal.valueOf(1500.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
PutLoansApprovedAmountResponse putLoansApprovedAmountResponse = modifyLoanApprovedAmount(loanId, fifteenHundred);
Assertions.assertEquals(loanId, putLoansApprovedAmountResponse.getResourceId());
Assertions.assertNotNull(putLoansApprovedAmountResponse.getChanges());
Assertions.assertNotNull(putLoansApprovedAmountResponse.getChanges().getNewApprovedAmount());
Assertions.assertNotNull(putLoansApprovedAmountResponse.getChanges().getOldApprovedAmount());
Assertions.assertEquals(fifteenHundred,
putLoansApprovedAmountResponse.getChanges().getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(thousand,
putLoansApprovedAmountResponse.getChanges().getOldApprovedAmount().setScale(1, RoundingMode.HALF_UP));
});
}
@Test
public void testModifyLoanApprovedAmountWithNegativeAmount() {
BigDecimal sixHundred = BigDecimal.valueOf(600.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, sixHundred.negate()));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("validation.msg.loan.approved.amount.amount.not.greater.than.zero"));
});
}
@Test
public void testModifyLoanApprovedAmountCapitalizedIncomeCountsAsPrincipal() {
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper
.createLoanProduct(create4IProgressiveWithCapitalizedIncome());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
disburseLoan(loanId, BigDecimal.valueOf(500), "1 January 2024");
PostLoansLoanIdTransactionsResponse capitalizedIncomeResponse = loanTransactionHelper.addCapitalizedIncome(loanId,
"1 January 2024", 500.0);
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(500.0)));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage()
.contains("validation.msg.loan.approved.amount.amount.less.than.disbursed.principal.and.capitalized.income"));
loanTransactionHelper.reverseLoanTransaction(capitalizedIncomeResponse.getLoanId(), capitalizedIncomeResponse.getResourceId(),
"1 January 2024");
Assertions.assertDoesNotThrow(() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(500.0)));
});
}
@Test
public void testModifyLoanApprovedAmountFutureExpectedDisbursementsCountAsPrincipal() {
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper
.createLoanProduct(create4IProgressive().disallowExpectedDisbursements(false).allowApprovedDisbursedAmountsOverApplied(null)
.overAppliedCalculationType(null).overAppliedNumber(null));
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 7.0, 6, (request) -> request.disbursementData(List.of(new PostLoansDisbursementData()
.expectedDisbursementDate("1 January 2024").principal(BigDecimal.valueOf(1000.0)))));
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(500.0)));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage()
.contains("validation.msg.loan.approved.amount.amount.less.than.disbursed.principal.and.capitalized.income"));
});
}
@Test
public void testModifyLoanApprovedAmountCreatesHistoryEntries() {
BigDecimal fourHundred = BigDecimal.valueOf(400.0);
BigDecimal sixHundred = BigDecimal.valueOf(600.0);
BigDecimal eightHundred = BigDecimal.valueOf(800.0);
BigDecimal thousand = BigDecimal.valueOf(1000.0);
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(800.0));
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(600.0));
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(400.0));
List<LoanApprovedAmountHistoryData> loanApprovedAmountHistory = getLoanApprovedAmountHistory(loanId);
Assertions.assertNotNull(loanApprovedAmountHistory);
Assertions.assertEquals(3, loanApprovedAmountHistory.size());
Assertions.assertEquals(thousand, loanApprovedAmountHistory.get(0).getOldApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(eightHundred,
loanApprovedAmountHistory.get(0).getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(eightHundred,
loanApprovedAmountHistory.get(1).getOldApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(sixHundred, loanApprovedAmountHistory.get(1).getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(sixHundred, loanApprovedAmountHistory.get(2).getOldApprovedAmount().setScale(1, RoundingMode.HALF_UP));
Assertions.assertEquals(fourHundred, loanApprovedAmountHistory.get(2).getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
});
}
@Test
public void testDisbursementValidationAfterApprovedAmountReduction() {
// Test that disbursement validation properly respects reduced approved amounts
// Scenario: Reduce approved amount and verify disbursements are limited to new amount
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
// Create loan with applied amount $1000
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
// Reduce approved amount to $900
PutLoansApprovedAmountResponse modifyResponse = modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(900.0));
assertEquals(BigDecimal.valueOf(900.0), modifyResponse.getChanges().getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
// Disburse $100 (should work as it's within approved amount)
Assertions.assertDoesNotThrow(() -> disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024"),
"Should be able to disburse $100 after reducing approved amount to $900");
// Disburse additional $250 (total $350, should work as it's within proposed $1000 × 150% = $1350)
Assertions.assertDoesNotThrow(() -> disburseLoan(loanId, BigDecimal.valueOf(250), "1 January 2024"),
"Should be able to disburse additional $250 (total $350) within allowed limit");
// Try to disburse additional $1200 (total $1550, should fail as it exceeds $1000 × 150% = $1350)
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> disburseLoan(loanId, BigDecimal.valueOf(1200), "1 January 2024"));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("amount.can't.be.greater.than.maximum.applied.loan.amount.calculation"),
"Should fail when total disbursements exceed modified approved amount × over-applied percentage");
});
}
@Test
public void testProgressiveDisbursementsWithDynamicApprovedAmountChanges() {
// Test multiple disbursements with increasing and decreasing approved amount modifications
// Validates that each disbursement respects the current approved amount limits
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
// Create loan with $1000 applied amount
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
// First disbursement: $300
disburseLoan(loanId, BigDecimal.valueOf(300), "1 January 2024");
// Increase approved amount to $1200
PutLoansApprovedAmountResponse increaseResponse = modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(1200.0));
assertEquals(BigDecimal.valueOf(1200.0),
increaseResponse.getChanges().getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
// Second disbursement: $400 (total $700, within $1200)
Assertions.assertDoesNotThrow(() -> disburseLoan(loanId, BigDecimal.valueOf(400), "1 January 2024"));
// Reduce approved amount to $800
PutLoansApprovedAmountResponse reduceResponse = modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(800.0));
assertEquals(BigDecimal.valueOf(800.0), reduceResponse.getChanges().getNewApprovedAmount().setScale(1, RoundingMode.HALF_UP));
// Third disbursement: $100 (total $800, within proposed $1000 × 150% = $1500)
Assertions.assertDoesNotThrow(() -> disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024"));
// Fourth disbursement: $800 (total $1600, should fail as it exceeds $1000 × 150% = $1500)
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> disburseLoan(loanId, BigDecimal.valueOf(800), "1 January 2024"));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("amount.can't.be.greater.than.maximum.applied.loan.amount.calculation"));
});
}
@Test
public void testApprovedAmountModificationWithCapitalizedIncomeScenario() {
// Test approved amount modification interaction with capitalized income
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper
.createLoanProduct(create4IProgressiveWithCapitalizedIncome());
runAt("1 January 2024", () -> {
// Create loan with $1000 applied amount
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
// Disburse $300
disburseLoan(loanId, BigDecimal.valueOf(300), "1 January 2024");
// Add capitalized income of $200 (total disbursed equivalent: $500)
loanTransactionHelper.addCapitalizedIncome(loanId, "1 January 2024", 200.0);
// Try to reduce approved amount to $400 (should fail as disbursed + capitalized = $500)
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(400.0)));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage()
.contains("validation.msg.loan.approved.amount.amount.less.than.disbursed.principal.and.capitalized.income"));
// Should succeed with $500 (exactly matching disbursed + capitalized)
Assertions.assertDoesNotThrow(() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(500.0)));
// Should succeed with $600 (above disbursed + capitalized)
Assertions.assertDoesNotThrow(() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(600.0)));
});
}
@Test
public void testUndoDisbursementAfterApprovedAmountReduction() {
// Test undo disbursement functionality after approved amount reduction
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(800.0));
disburseLoan(loanId, BigDecimal.valueOf(600), "1 January 2024");
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
if (loanDetails.getSummary() != null && loanDetails.getSummary().getPrincipalDisbursed() != null) {
assertEquals(BigDecimal.valueOf(600.0), loanDetails.getSummary().getPrincipalDisbursed().setScale(1, RoundingMode.HALF_UP));
}
PostLoansLoanIdRequest undoRequest = new PostLoansLoanIdRequest().note("Undo disbursement for testing");
Assertions.assertDoesNotThrow(() -> loanTransactionHelper.undoDisbursalLoan(loanId, undoRequest));
GetLoansLoanIdResponse loanDetailsAfterUndo = loanTransactionHelper.getLoanDetails(loanId);
BigDecimal activeDisbursedAmount = BigDecimal.ZERO;
if (loanDetailsAfterUndo.getTransactions() != null && !loanDetailsAfterUndo.getTransactions().isEmpty()) {
activeDisbursedAmount = loanDetailsAfterUndo.getTransactions().stream()
.filter(transaction -> transaction.getType() != null && "Disbursement".equals(transaction.getType().getValue()))
.filter(transaction -> !Boolean.TRUE.equals(transaction.getManuallyReversed()))
.map(GetLoansLoanIdTransactions::getAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
assertEquals(0, BigDecimal.ZERO.compareTo(activeDisbursedAmount));
Assertions.assertDoesNotThrow(() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(400.0)));
GetLoansLoanIdResponse finalLoanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertEquals(BigDecimal.valueOf(400.0), finalLoanDetails.getApprovedPrincipal().setScale(1, RoundingMode.HALF_UP));
});
}
@Test
public void testUndoLastDisbursementWithMultipleDisbursements() {
// Test undo last disbursement in multi-disbursement scenario with approved amount modifications
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
disburseLoan(loanId, BigDecimal.valueOf(300), "1 January 2024");
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(1200.0));
disburseLoan(loanId, BigDecimal.valueOf(400), "1 January 2024");
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(800.0));
disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024");
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
if (loanDetails.getSummary() != null && loanDetails.getSummary().getPrincipalDisbursed() != null) {
assertEquals(BigDecimal.valueOf(800.0), loanDetails.getSummary().getPrincipalDisbursed().setScale(1, RoundingMode.HALF_UP));
}
PostLoansLoanIdRequest undoLastRequest = new PostLoansLoanIdRequest().note("Undo last disbursement");
Assertions.assertDoesNotThrow(() -> loanTransactionHelper.undoLastDisbursalLoan(loanId, undoLastRequest));
GetLoansLoanIdResponse loanDetailsAfterUndo = loanTransactionHelper.getLoanDetails(loanId);
BigDecimal activeDisbursedAmount = BigDecimal.ZERO;
if (loanDetailsAfterUndo.getTransactions() != null && !loanDetailsAfterUndo.getTransactions().isEmpty()) {
activeDisbursedAmount = loanDetailsAfterUndo.getTransactions().stream()
.filter(transaction -> transaction.getType() != null && "Disbursement".equals(transaction.getType().getValue()))
.filter(transaction -> !Boolean.TRUE.equals(transaction.getManuallyReversed()))
.map(GetLoansLoanIdTransactions::getAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
assertEquals(BigDecimal.valueOf(700.0), activeDisbursedAmount.setScale(1, RoundingMode.HALF_UP));
Assertions.assertDoesNotThrow(() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(700.0)));
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(600.0)));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage()
.contains("validation.msg.loan.approved.amount.amount.less.than.disbursed.principal.and.capitalized.income"));
});
}
@Test
public void testDisbursementValidationAfterUndoWithReducedApprovedAmount() {
// Test disbursement validation after undo disbursement with reduced approved amount
final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
final PostLoanProductsResponse loanProductsResponse = loanProductHelper.createLoanProduct(create4IProgressive());
runAt("1 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024",
1000.0, 10.0, 4, null);
modifyLoanApprovedAmount(loanId, BigDecimal.valueOf(600.0));
disburseLoan(loanId, BigDecimal.valueOf(500), "1 January 2024");
PostLoansLoanIdRequest undoRequest = new PostLoansLoanIdRequest().note("Undo for testing validation");
loanTransactionHelper.undoDisbursalLoan(loanId, undoRequest);
Assertions.assertDoesNotThrow(() -> disburseLoan(loanId, BigDecimal.valueOf(700), "1 January 2024"));
loanTransactionHelper.undoDisbursalLoan(loanId, undoRequest);
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> disburseLoan(loanId, BigDecimal.valueOf(1600), "1 January 2024"));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("amount.can't.be.greater.than.maximum.applied.loan.amount.calculation"));
});
}
}
@@ -657,6 +657,11 @@ public class ExternalEventConfigurationHelper {
loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent);
Map<String, Object> loanApprovedAmountChangedBusinessEvent = new HashMap<>();
loanApprovedAmountChangedBusinessEvent.put("type", "LoanApprovedAmountChangedBusinessEvent");
loanApprovedAmountChangedBusinessEvent.put("enabled", false);
defaults.add(loanApprovedAmountChangedBusinessEvent);
return defaults;
}
@@ -29,7 +29,9 @@ import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PutLoansApprovedAmountRequest;
import org.apache.fineract.client.util.Calls;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.integrationtests.common.BusinessDateHelper;
import org.apache.fineract.integrationtests.common.FineractClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
@@ -52,6 +54,13 @@ public class LoanTestLifecycleExtension implements AfterEachCallback {
loanIds.forEach(loanId -> {
GetLoansLoanIdResponse loanResponse = Calls
.ok(FineractClientHelper.getFineractClient().loans.retrieveLoan((long) loanId, null, "all", null, null));
if (MathUtil.isLessThan(loanResponse.getApprovedPrincipal(), loanResponse.getProposedPrincipal())) {
// reset approved principal in case it's less than proposed principal so all expected disbursements
// can be properly disbursed
PutLoansApprovedAmountRequest request = new PutLoansApprovedAmountRequest().amount(loanResponse.getProposedPrincipal())
.locale("en");
Calls.ok(FineractClientHelper.getFineractClient().loans.modifyLoanApprovedAmount(loanId, request));
}
loanResponse.getDisbursementDetails().forEach(disbursementDetail -> {
if (disbursementDetail.getActualDisbursementDate() == null) {
loanTransactionHelper.disburseLoan((long) loanId,