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
@@ -3876,4 +3876,13 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/transactions/template?command=buyDownFee";
return this;
}
public CommandWrapperBuilder updateLoanApprovedAmount(final Long loanId) {
this.actionName = "UPDATE";
this.entityName = "LOAN_APPROVED_AMOUNT";
this.entityId = loanId;
this.loanId = loanId;
this.href = "/loans/" + loanId;
return this;
}
}
@@ -23,6 +23,7 @@ import java.util.List;
import java.util.function.Consumer;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
public final class Validator {
@@ -30,14 +31,28 @@ public final class Validator {
private Validator() {}
public static void validateOrThrow(String resource, Consumer<DataValidatorBuilder> baseDataValidator) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder dataValidatorBuilder = new DataValidatorBuilder(dataValidationErrors).resource(resource);
baseDataValidator.accept(dataValidatorBuilder);
final List<ApiParameterError> dataValidationErrors = getApiParameterErrors(resource, baseDataValidator);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
public static void validateOrThrowDomainViolation(String resource, Consumer<DataValidatorBuilder> baseDataValidator) {
final List<ApiParameterError> dataValidationErrors = getApiParameterErrors(resource, baseDataValidator);
if (!dataValidationErrors.isEmpty()) {
throw new GeneralPlatformDomainRuleException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors.toArray(new Object[0]));
}
}
private static List<ApiParameterError> getApiParameterErrors(String resource, Consumer<DataValidatorBuilder> baseDataValidator) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder dataValidatorBuilder = new DataValidatorBuilder(dataValidationErrors).resource(resource);
baseDataValidator.accept(dataValidatorBuilder);
return dataValidationErrors;
}
}
@@ -65,7 +65,7 @@ public final class ErrorMessageHelper {
}
public static String addDisbursementExceedApprovedAmountFailure() {
return "Loan can't be disbursed,disburse amount is exceeding approved principal ";
return "Loan can't be disbursed, disburse amount is exceeding approved principal.";
}
public static String addDisbursementExceedMaxAppliedAmountFailure(String totalDisbAmount, String maxDisbursalAmount) {
@@ -980,4 +980,12 @@ public final class ErrorMessageHelper {
public static String addInstallmentFeePrincipalPercentageChargeFailure() {
return "Failed data validation due to: installment.loancharge.with.calculation.type.principal.not.allowed.";
}
public static String updateApprovedLoanExceedPrincipalFailure() {
return "Failed data validation due to: less.than.disbursed.principal.and.capitalized.income.";
}
public static String updateApprovedLoanLessMinAllowedAmountFailure() {
return "The parameter `amount` must be greater than 0.";
}
}
@@ -61,5 +61,14 @@ public class ErrorResponse {
public static class Error {
private String developerMessage;
private List<ErrorMessageArg> args;
}
@NoArgsConstructor
@Getter
@Setter
public static class ErrorMessageArg {
private Object value;
}
}
@@ -24,6 +24,7 @@ import static org.apache.fineract.test.data.loanproduct.DefaultLoanProduct.LP2_A
import static org.apache.fineract.test.data.loanproduct.DefaultLoanProduct.LP2_ADV_PYMNT_INTEREST_DAILY_INTEREST_RECALCULATION_ZERO_INTEREST_CHARGE_OFF_BEHAVIOUR;
import static org.apache.fineract.test.data.loanproduct.DefaultLoanProduct.LP2_ADV_PYMNT_ZERO_INTEREST_CHARGE_OFF_BEHAVIOUR;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.CHARGE_OFF_REASONS;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.LOCALE_EN;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -98,6 +99,8 @@ import org.apache.fineract.client.models.PostLoansRequestChargeData;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.client.models.PutLoanProductsProductIdRequest;
import org.apache.fineract.client.models.PutLoanProductsProductIdResponse;
import org.apache.fineract.client.models.PutLoansApprovedAmountRequest;
import org.apache.fineract.client.models.PutLoansApprovedAmountResponse;
import org.apache.fineract.client.models.PutLoansLoanIdRequest;
import org.apache.fineract.client.models.PutLoansLoanIdResponse;
import org.apache.fineract.client.services.BusinessDateManagementApi;
@@ -574,6 +577,12 @@ public class LoanStepDef extends AbstractStepDef {
createFullyCustomizedLoanWithExpectedTrancheDisbursementsDetails(data.get(1));
}
@When("Admin creates a fully customized loan with three expected disbursements details and following data:")
public void createFullyCustomizedLoanWithThreeDisbursementsDetails(final DataTable table) throws IOException {
final List<List<String>> data = table.asLists();
createFullyCustomizedLoanWithThreeExpectedTrancheDisbursementsDetails(data.get(1));
}
@When("Admin creates a fully customized loan with forced disabled downpayment with the following data:")
public void createFullyCustomizedLoanWithForcedDisabledDownpayment(DataTable table) throws IOException {
List<List<String>> data = table.asLists();
@@ -3688,6 +3697,27 @@ public class LoanStepDef extends AbstractStepDef {
createFullyCustomizedLoanExpectsTrancheDisbursementDetails(loanData, disbursementDetail);
}
public void createFullyCustomizedLoanWithThreeExpectedTrancheDisbursementsDetails(final List<String> loanData) throws IOException {
final String expectedDisbursementDateFirstDisbursal = loanData.get(16);
final Double disbursementPrincipalAmountFirstDisbursal = Double.valueOf(loanData.get(17));
final String expectedDisbursementDateSecondDisbursal = loanData.get(18);
final Double disbursementPrincipalAmountSecondDisbursal = Double.valueOf(loanData.get(19));
final String expectedDisbursementDateThirdDisbursal = loanData.get(20);
final Double disbursementPrincipalAmountThirdDisbursal = Double.valueOf(loanData.get(21));
List<PostLoansDisbursementData> disbursementDetail = new ArrayList<>();
disbursementDetail.add(new PostLoansDisbursementData().expectedDisbursementDate(expectedDisbursementDateFirstDisbursal)
.principal(BigDecimal.valueOf(disbursementPrincipalAmountFirstDisbursal)));
disbursementDetail.add(new PostLoansDisbursementData().expectedDisbursementDate(expectedDisbursementDateSecondDisbursal)
.principal(BigDecimal.valueOf(disbursementPrincipalAmountSecondDisbursal)));
disbursementDetail.add(new PostLoansDisbursementData().expectedDisbursementDate(expectedDisbursementDateThirdDisbursal)
.principal(BigDecimal.valueOf(disbursementPrincipalAmountThirdDisbursal)));
createFullyCustomizedLoanExpectsTrancheDisbursementDetails(loanData, disbursementDetail);
}
public void createFullyCustomizedLoanExpectsTrancheDisbursementDetails(final List<String> loanData,
List<PostLoansDisbursementData> disbursementDetail) throws IOException {
final String loanProduct = loanData.get(0);
@@ -4859,4 +4889,72 @@ public class LoanStepDef extends AbstractStepDef {
log.debug("BuyDown Fee Adjustment created: Transaction ID {}", adjustmentResponse.body().getResourceId());
}
@Then("Update loan approved amount with new amount {string} value")
public void updateLoanApprovedAmount(final String amount) throws IOException {
final Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
final long loanId = loanResponse.body().getLoanId();
final Response<GetLoansLoanIdResponse> loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute();
ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse);
final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN)
.amount(new BigDecimal(amount));
final Response<PutLoansApprovedAmountResponse> modifyLoanApprovedAmountResponse = loansApi
.modifyLoanApprovedAmount(loanId, modifyLoanApprovedAmountRequest).execute();
ErrorHelper.checkSuccessfulApiCall(modifyLoanApprovedAmountResponse);
}
@Then("Update loan approved amount is forbidden with amount {string} due to exceed applied amount")
public void updateLoanApprovedAmountForbiddenExceedAppliedAmount(final String amount) throws IOException {
final Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
final long loanId = loanResponse.body().getLoanId();
final Response<GetLoansLoanIdResponse> loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute();
ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse);
final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN)
.amount(new BigDecimal(amount));
final Response<PutLoansApprovedAmountResponse> modifyLoanApprovedAmountResponse = loansApi
.modifyLoanApprovedAmount(loanId, modifyLoanApprovedAmountRequest).execute();
ErrorResponse errorDetails = ErrorResponse.from(modifyLoanApprovedAmountResponse);
assertThat(errorDetails.getHttpStatusCode()).isEqualTo(403);
Object errorArgs = errorDetails.getErrors().getFirst().getArgs().getFirst().getValue();
String developerMessage;
if (errorArgs instanceof Map errorArgsMap) {
developerMessage = (String) errorArgsMap.get("developerMessage");
} else {
developerMessage = errorDetails.getDeveloperMessage();
}
assertThat(developerMessage).isEqualTo(ErrorMessageHelper.updateApprovedLoanExceedPrincipalFailure());
}
@Then("Update loan approved amount is forbidden with amount {string} due to min allowed amount")
public void updateLoanApprovedAmountForbiddenMinAllowedAmount(final String amount) throws IOException {
final Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
final long loanId = loanResponse.body().getLoanId();
final Response<GetLoansLoanIdResponse> loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute();
ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse);
final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN)
.amount(new BigDecimal(amount));
final Response<PutLoansApprovedAmountResponse> modifyLoanApprovedAmountResponse = loansApi
.modifyLoanApprovedAmount(loanId, modifyLoanApprovedAmountRequest).execute();
ErrorResponse errorDetails = ErrorResponse.from(modifyLoanApprovedAmountResponse);
assertThat(errorDetails.getHttpStatusCode()).isEqualTo(400);
Object errorArgs = errorDetails.getErrors().getFirst().getArgs().getFirst().getValue();
String developerMessage;
if (errorArgs instanceof Map errorArgsMap) {
developerMessage = (String) errorArgsMap.get("developerMessage");
} else {
developerMessage = errorDetails.getDeveloperMessage();
}
assertThat(developerMessage).isEqualTo(ErrorMessageHelper.updateApprovedLoanLessMinAllowedAmountFailure());
}
}
@@ -0,0 +1,35 @@
/**
* 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.infrastructure.event.business.domain.loan;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
public class LoanApprovedAmountChangedBusinessEvent extends LoanBusinessEvent {
private static final String TYPE = "LoanApprovedAmountChangedBusinessEvent";
public LoanApprovedAmountChangedBusinessEvent(Loan value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -0,0 +1,47 @@
/**
* 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.portfolio.loanaccount.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
/**
* Immutable object representing an Approved Amount change operation on a Loan
*
* Note: no getter/setters required as google-gson will produce json from fields of object.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class LoanApprovedAmountHistoryData implements Serializable {
private Long loanId;
private ExternalId externalLoanId;
private BigDecimal newApprovedAmount;
private BigDecimal oldApprovedAmount;
private OffsetDateTime dateOfChange;
}
@@ -0,0 +1,47 @@
/**
* 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.portfolio.loanaccount.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
@Entity
@Table(name = "m_loan_approved_amount_history")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoanApprovedAmountHistory extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "loan_id", nullable = false)
private Long loanId;
@Column(name = "new_approved_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal newApprovedAmount;
@Column(name = "old_approved_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal oldApprovedAmount;
}
@@ -0,0 +1,39 @@
/**
* 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.portfolio.loanaccount.domain;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.data.LoanApprovedAmountHistoryData;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
public interface LoanApprovedAmountHistoryRepository
extends JpaRepository<LoanApprovedAmountHistory, Long>, JpaSpecificationExecutor<LoanApprovedAmountHistory> {
@Query("""
SELECT NEW org.apache.fineract.portfolio.loanaccount.data.LoanApprovedAmountHistoryData(
laah.loanId, l.externalId, laah.newApprovedAmount, laah.oldApprovedAmount, laah.createdDate
)
FROM LoanApprovedAmountHistory laah JOIN Loan l on laah.loanId = l.id
WHERE laah.loanId = :loanId
""")
List<LoanApprovedAmountHistoryData> findAllByLoanId(Long loanId, Pageable pageable);
}
@@ -0,0 +1,42 @@
/**
* 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.portfolio.loanaccount.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanaccount.service.LoanApprovedAmountWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@CommandType(entity = "LOAN_APPROVED_AMOUNT", action = "UPDATE")
public class LoanApprovedAmountModificationCommandHandler implements NewCommandSourceHandler {
private final LoanApprovedAmountWritePlatformService loanApprovedAmountWritePlatformService;
@Override
@Transactional
public CommandProcessingResult processCommand(JsonCommand command) {
return loanApprovedAmountWritePlatformService.modifyLoanApprovedAmount(command.getLoanId(), command);
}
}
@@ -0,0 +1,26 @@
/**
* 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.portfolio.loanaccount.serialization;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
public interface LoanApprovedAmountValidator {
void validateLoanApprovedAmountModification(JsonCommand command);
}
@@ -0,0 +1,27 @@
/**
* 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.portfolio.loanaccount.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface LoanApprovedAmountWritePlatformService {
CommandProcessingResult modifyLoanApprovedAmount(Long loanId, JsonCommand command);
}
@@ -0,0 +1,76 @@
/**
* 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.portfolio.loanaccount.service;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanApprovedAmountChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory;
import org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistoryRepository;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanApprovedAmountValidator;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoanApprovedAmountWritePlatformServiceImpl implements LoanApprovedAmountWritePlatformService {
private final LoanAssembler loanAssembler;
private final LoanApprovedAmountValidator loanApprovedAmountValidator;
private final LoanApprovedAmountHistoryRepository loanApprovedAmountHistoryRepository;
private final BusinessEventNotifierService businessEventNotifierService;
@Override
public CommandProcessingResult modifyLoanApprovedAmount(final Long loanId, final JsonCommand command) {
// API rule validations
this.loanApprovedAmountValidator.validateLoanApprovedAmountModification(command);
final Map<String, Object> changes = new LinkedHashMap<>();
changes.put("newApprovedAmount", command.stringValueOfParameterNamed(LoanApiConstants.amountParameterName));
changes.put("locale", command.locale());
Loan loan = this.loanAssembler.assembleFrom(loanId);
changes.put("oldApprovedAmount", loan.getApprovedPrincipal());
BigDecimal newApprovedAmount = command.bigDecimalValueOfParameterNamed(LoanApiConstants.amountParameterName);
LoanApprovedAmountHistory loanApprovedAmountHistory = new LoanApprovedAmountHistory(loan.getId(), newApprovedAmount,
loan.getApprovedPrincipal());
loan.setApprovedPrincipal(newApprovedAmount);
loanApprovedAmountHistoryRepository.saveAndFlush(loanApprovedAmountHistory);
businessEventNotifierService.notifyPostBusinessEvent(new LoanApprovedAmountChangedBusinessEvent(loan));
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
.withEntityId(loan.getId()) //
.withEntityExternalId(loan.getExternalId()) //
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withGroupId(loan.getGroupId()) //
.with(changes) //
.build();
}
}
@@ -86,6 +86,7 @@
<class>org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTag</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonitoringAccount</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.Loan</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCharge</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy</class>
<class>org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement</class>
@@ -127,12 +127,14 @@ import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.GlimRepaymentTemplate;
import org.apache.fineract.portfolio.loanaccount.data.LoanAccountData;
import org.apache.fineract.portfolio.loanaccount.data.LoanApprovalData;
import org.apache.fineract.portfolio.loanaccount.data.LoanApprovedAmountHistoryData;
import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData;
import org.apache.fineract.portfolio.loanaccount.data.LoanCollateralManagementData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
import org.apache.fineract.portfolio.loanaccount.data.PaidInAdvanceData;
import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistoryRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeCalculationType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeIncomeType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeStrategy;
@@ -176,6 +178,8 @@ import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
@@ -303,6 +307,7 @@ public class LoansApiResource {
private final LoanTermVariationsRepository loanTermVariationsRepository;
private final LoanSummaryProviderDelegate loanSummaryProviderDelegate;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
private final LoanApprovedAmountHistoryRepository loanApprovedAmountHistoryRepository;
/*
* This template API is used for loan approval, ideally this should be invoked on loan that are pending for
@@ -872,6 +877,55 @@ public class LoansApiResource {
return createLoanDelinquencyAction(null, ExternalIdFactory.produce(loanExternalId), apiRequestBodyAsJson);
}
@PUT
@Path("{loanId}/approved-amount")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Modifies the approved amount of the loan", description = "")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = LoansApiResourceSwagger.PutLoansApprovedAmountRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoansApiResourceSwagger.PutLoansApprovedAmountResponse.class))) })
public CommandProcessingResult modifyLoanApprovedAmount(
@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, @Context final UriInfo uriInfo,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
return modifyLoanApprovedAmount(loanId, ExternalId.empty(), apiRequestBodyAsJson);
}
@PUT
@Path("external-id/{loanExternalId}/approved-amount")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Modifies the approved amount of the loan", description = "")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = LoansApiResourceSwagger.PutLoansApprovedAmountRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoansApiResourceSwagger.PutLoansApprovedAmountResponse.class))) })
public CommandProcessingResult modifyLoanApprovedAmount(
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
@Context final UriInfo uriInfo, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return modifyLoanApprovedAmount(null, ExternalIdFactory.produce(loanExternalId), apiRequestBodyAsJson);
}
@GET
@Path("{loanId}/approved-amount")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Collects and returns the approved amount modification history for a given loan", description = "")
public List<LoanApprovedAmountHistoryData> getLoanApprovedAmountHistory(
@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, @Context final UriInfo uriInfo) {
return getLoanApprovedAmountHistory(loanId, ExternalId.empty());
}
@GET
@Path("external-id/{loanExternalId}/approved-amount")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Collects and returns the approved amount modification history for a given loan", description = "")
public List<LoanApprovedAmountHistoryData> getLoanApprovedAmountHistory(
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
@Context final UriInfo uriInfo) {
return getLoanApprovedAmountHistory(null, ExternalIdFactory.produce(loanExternalId));
}
private String retrieveApprovalTemplate(final Long loanId, final String loanExternalIdStr, final String templateType,
final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
@@ -1294,4 +1348,19 @@ public class LoansApiResource {
return delinquencyActionSerializer.serialize(result);
}
private CommandProcessingResult modifyLoanApprovedAmount(Long loanId, ExternalId loanExternalId, String apiRequestBodyAsJson) {
Long resolvedLoanId = getResolvedLoanId(loanId, loanExternalId);
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
CommandWrapper commandRequest = builder.updateLoanApprovedAmount(resolvedLoanId).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
private List<LoanApprovedAmountHistoryData> getLoanApprovedAmountHistory(Long loanId, ExternalId loanExternalId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
Long resolvedLoanId = getResolvedLoanId(loanId, loanExternalId);
Pageable sortedByCreationDate = Pageable.unpaged(Sort.by("createdDate").ascending());
return loanApprovedAmountHistoryRepository.findAllByLoanId(resolvedLoanId, sortedByCreationDate);
}
}
@@ -1771,4 +1771,47 @@ final class LoansApiResourceSwagger {
@Schema(description = "PostLoansLoanIdChanges")
public PostLoansLoanIdChanges changes;
}
@Schema(description = "PutLoansApprovedAmountRequest")
public static final class PutLoansApprovedAmountRequest {
private PutLoansApprovedAmountRequest() {}
@Schema(example = "1000")
public BigDecimal amount;
@Schema(example = "en")
public String locale;
}
@Schema(description = "PutLoansApprovedAmountResponse")
public static final class PutLoansApprovedAmountResponse {
private PutLoansApprovedAmountResponse() {}
static final class PutLoansApprovedAmountChanges {
private PutLoansApprovedAmountChanges() {}
@Schema(example = "1000")
public BigDecimal oldApprovedAmount;
@Schema(example = "1000")
public BigDecimal newApprovedAmount;
@Schema(example = "en_GB")
public String locale;
}
@Schema(example = "3")
public Long resourceId;
@Schema(example = "95174ff9-1a75-4d72-a413-6f9b1cb988b7")
public String resourceExternalId;
@Schema(example = "2")
public Long officeId;
@Schema(example = "6")
public Long clientId;
@Schema(example = "10")
public Long groupId;
@Schema(description = "PutLoansApprovedAmountChanges")
public PutLoansApprovedAmountChanges changes;
}
}
@@ -2174,12 +2174,33 @@ public final class LoanApplicationValidator {
public BigDecimal getOverAppliedMax(Loan loan) {
LoanProduct loanProduct = loan.getLoanProduct();
// Check if overapplied calculation type and number are properly configured
if (loanProduct.getOverAppliedCalculationType() == null || loanProduct.getOverAppliedNumber() == null) {
// If overapplied calculation is not configured, return proposed principal (original behavior)
return loan.getProposedPrincipal();
}
// For loans with approved amount modifications, use proposed principal as base to allow
// disbursement up to the originally requested amount regardless of the reduced approved amount
boolean hasApprovedAmountModification = loan.getApprovedPrincipal() != null && loan.getProposedPrincipal() != null
&& loan.getApprovedPrincipal().compareTo(loan.getProposedPrincipal()) != 0;
BigDecimal basePrincipal;
if (hasApprovedAmountModification) {
// Use proposed principal for loans with approved amount modifications
basePrincipal = loan.getProposedPrincipal();
} else {
// Use approved principal for normal loans
basePrincipal = loan.getApprovedPrincipal() != null ? loan.getApprovedPrincipal() : loan.getProposedPrincipal();
}
if ("percentage".equals(loanProduct.getOverAppliedCalculationType())) {
BigDecimal overAppliedNumber = BigDecimal.valueOf(loanProduct.getOverAppliedNumber());
BigDecimal totalPercentage = BigDecimal.valueOf(1).add(overAppliedNumber.divide(BigDecimal.valueOf(100)));
return loan.getProposedPrincipal().multiply(totalPercentage);
return basePrincipal.multiply(totalPercentage);
} else {
return loan.getProposedPrincipal().add(BigDecimal.valueOf(loanProduct.getOverAppliedNumber()));
return basePrincipal.add(BigDecimal.valueOf(loanProduct.getOverAppliedNumber()));
}
}
@@ -0,0 +1,104 @@
/**
* 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.portfolio.loanaccount.serialization;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.common.service.Validator;
import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public final class LoanApprovedAmountValidatorImpl implements LoanApprovedAmountValidator {
private static final Set<LoanStatus> INVALID_LOAN_STATUSES_FOR_APPROVED_AMOUNT_MODIFICATION = Set.of(LoanStatus.INVALID,
LoanStatus.SUBMITTED_AND_PENDING_APPROVAL, LoanStatus.REJECTED);
private final FromJsonHelper fromApiJsonHelper;
private final LoanRepository loanRepository;
private final LoanApplicationValidator loanApplicationValidator;
@Override
public void validateLoanApprovedAmountModification(JsonCommand command) {
String json = command.json();
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Set<String> supportedParameters = new HashSet<>(
Arrays.asList(LoanApiConstants.amountParameterName, LoanApiConstants.localeParameterName));
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParameters);
final BigDecimal newApprovedAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LoanApiConstants.amountParameterName,
element);
Validator.validateOrThrow("loan.approved.amount", baseDataValidator -> {
baseDataValidator.reset().parameter(LoanApiConstants.amountParameterName).value(newApprovedAmount).notNull();
});
Validator.validateOrThrowDomainViolation("loan.approved.amount", baseDataValidator -> {
baseDataValidator.reset().parameter(LoanApiConstants.amountParameterName).value(newApprovedAmount).positiveAmount();
final Long loanId = command.getLoanId();
Loan loan = this.loanRepository.findById(loanId).orElseThrow(() -> new LoanNotFoundException(loanId));
if (INVALID_LOAN_STATUSES_FOR_APPROVED_AMOUNT_MODIFICATION.contains(loan.getStatus())) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("loan.status.not.valid.for.approved.amount.modification");
}
BigDecimal maximumThresholdForApprovedAmount;
if (loan.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) {
maximumThresholdForApprovedAmount = loanApplicationValidator.getOverAppliedMax(loan);
} else {
maximumThresholdForApprovedAmount = loan.getProposedPrincipal();
}
if (MathUtil.isGreaterThan(newApprovedAmount, maximumThresholdForApprovedAmount)) {
baseDataValidator.reset().parameter(LoanApiConstants.amountParameterName)
.failWithCode("can't.be.greater.than.maximum.applied.loan.amount.calculation");
}
BigDecimal totalPrincipalOnLoan = loan.getSummary().getTotalPrincipal();
if (MathUtil.isLessThan(newApprovedAmount, totalPrincipalOnLoan)) {
baseDataValidator.reset().parameter(LoanApiConstants.amountParameterName)
.failWithCode("less.than.disbursed.principal.and.capitalized.income");
}
});
}
}
@@ -44,7 +44,7 @@ public final class LoanDisbursementValidator {
} else {
if ((totalDisbursed.compareTo(loan.getApprovedPrincipal()) > 0)
|| (totalDisbursed.add(capitalizedIncome).compareTo(loan.getApprovedPrincipal()) > 0)) {
final String errorMsg = "Loan can't be disbursed,disburse amount is exceeding approved principal ";
final String errorMsg = "Loan can't be disbursed, disburse amount is exceeding approved principal.";
throw new LoanDisbursalException(errorMsg, "disburse.amount.must.be.less.than.approved.principal", totalDisbursed,
loan.getApprovedPrincipal());
}
@@ -158,7 +158,7 @@ public final class LoanTransactionValidatorImpl implements LoanTransactionValida
validateLoanClientIsActive(loan);
validateLoanGroupIsActive(loan);
final BigDecimal disbursedAmount = loan.getDisbursedAmount();
final BigDecimal disbursedAmount = loan.getSummary().getTotalPrincipalDisbursed();
loanDisbursementValidator.compareDisbursedToApprovedOrProposedPrincipal(loan, principal, disbursedAmount);
if (loan.isChargedOff()) {
@@ -161,6 +161,7 @@ public class LoanDisbursementService {
} else {
loan.getLoanRepaymentScheduleDetail()
.setPrincipal(loan.getLoanRepaymentScheduleDetail().getPrincipal().minus(diff).getAmount());
totalAmount = loan.getLoanRepaymentScheduleDetail().getPrincipal().getAmount();
}
loanDisbursementValidator.compareDisbursedToApprovedOrProposedPrincipal(loan, disburseAmount.getAmount(), totalAmount);
}
@@ -432,7 +432,7 @@ public class LoanAccountConfiguration {
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService,
LoanAccountingBridgeMapper loanAccountingBridgeMapper, LoanMapper loanMapper,
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
LoanTransactionService loanTransactionService, BuyDownFeePlatformService buyDownFeePlatformService) {
LoanTransactionService loanTransactionService) {
return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer,
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
loanTransactionRelationRepository, loanAssembler, journalEntryWritePlatformService, calendarInstanceRepository,
@@ -209,4 +209,6 @@
<include file="parts/0188_create_loan_buy_down_fee_balance.xml" relativeToChangelogFile="true" />
<include file="parts/0189_add_loan_buydown_fee_event.xml" relativeToChangelogFile="true" />
<include file="parts/0190_buy_down_fee_amortization.xml" relativeToChangelogFile="true" />
<include file="parts/0191_add_LoanApprovedAmountChangedBusinessEvent.xml" relativeToChangelogFile="true" />
<include file="parts/0192_create_loan_approved_amount_history.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="1" author="fineract">
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanApprovedAmountChangedBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1" context="postgresql">
<createTable tableName="m_loan_approved_amount_history">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true" primaryKeyName="pk_m_loan_approved_amount_history"/>
</column>
<column name="loan_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="new_approved_amount" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
<column name="old_approved_amount" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_on_utc" type="timestamp with time zone"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_on_utc" type="timestamp with time zone"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="2" context="mysql">
<createTable tableName="m_loan_approved_amount_history">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true" primaryKeyName="pk_m_loan_approved_amount_history"/>
</column>
<column name="loan_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="new_approved_amount" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
<column name="old_approved_amount" type="DECIMAL(19, 6)">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_on_utc" type="DATETIME"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_on_utc" type="DATETIME"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="3">
<addForeignKeyConstraint baseColumnNames="created_by" baseTableName="m_loan_approved_amount_history"
constraintName="FK_loan_approved_amount_history_created_by" deferrable="false" initiallyDeferred="false"
onDelete="RESTRICT" onUpdate="RESTRICT" referencedColumnNames="id"
referencedTableName="m_appuser" validate="true"/>
<addForeignKeyConstraint baseColumnNames="last_modified_by" baseTableName="m_loan_approved_amount_history"
constraintName="FK_loan_approved_amount_history_last_modified_by" deferrable="false" initiallyDeferred="false"
onDelete="RESTRICT" onUpdate="RESTRICT" referencedColumnNames="id"
referencedTableName="m_appuser" validate="true"/>
<addForeignKeyConstraint baseColumnNames="loan_id" baseTableName="m_loan_approved_amount_history"
constraintName="FK_loan_approved_amount_history_loan_id" deferrable="false" initiallyDeferred="false"
onDelete="RESTRICT" onUpdate="RESTRICT" referencedColumnNames="id"
referencedTableName="m_loan" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -112,7 +112,7 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent",
"LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent",
"LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent",
"LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent");
"LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent", "LoanApprovedAmountChangedBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -206,7 +206,7 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent",
"LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent",
"LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent",
"LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent");
"LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent", "LoanApprovedAmountChangedBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -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,