This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit 6d65f0792484e2413de9d4e10bdbff8d1ebfbb0a Author: mark.vituska <[email protected]> AuthorDate: Tue Jul 1 13:08:05 2025 +0200 FINERACT-2317: Add support for modifying loan approved amounts with validation and history tracking --- .../commands/service/CommandWrapperBuilder.java | 9 + .../portfolio/common/service/Validator.java | 23 +- .../fineract/test/helper/ErrorMessageHelper.java | 10 +- .../apache/fineract/test/helper/ErrorResponse.java | 9 + .../fineract/test/stepdef/loan/LoanStepDef.java | 98 +++++ .../LoanApprovedAmountChangedBusinessEvent.java | 35 ++ .../data/LoanApprovedAmountHistoryData.java | 47 ++ .../domain/LoanApprovedAmountHistory.java | 47 ++ .../LoanApprovedAmountHistoryRepository.java | 39 ++ ...anApprovedAmountModificationCommandHandler.java | 42 ++ .../serialization/LoanApprovedAmountValidator.java | 26 ++ .../LoanApprovedAmountWritePlatformService.java | 27 ++ ...LoanApprovedAmountWritePlatformServiceImpl.java | 76 ++++ .../module/fineract-loan/persistence.xml | 1 + .../loanaccount/api/LoansApiResource.java | 69 +++ .../loanaccount/api/LoansApiResourceSwagger.java | 43 ++ .../serialization/LoanApplicationValidator.java | 25 +- .../LoanApprovedAmountValidatorImpl.java | 104 +++++ .../serialization/LoanDisbursementValidator.java | 2 +- .../LoanTransactionValidatorImpl.java | 2 +- .../service/LoanDisbursementService.java | 1 + .../starter/LoanAccountConfiguration.java | 2 +- .../db/changelog/tenant/changelog-tenant.xml | 2 + ..._add_LoanApprovedAmountChangedBusinessEvent.xml | 31 ++ .../0192_create_loan_approved_amount_history.xml | 79 ++++ ...nalEventConfigurationValidationServiceTest.java | 4 +- .../integrationtests/BaseLoanIntegrationTest.java | 12 + .../LoanModifyApprovedAmountTests.java | 481 +++++++++++++++++++++ .../common/ExternalEventConfigurationHelper.java | 5 + .../common/loans/LoanTestLifecycleExtension.java | 9 + 30 files changed, 1348 insertions(+), 12 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index a3f2c14c4b..42f23b6bac 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -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; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/common/service/Validator.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/common/service/Validator.java index eb1f3237f4..c953bb545b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/common/service/Validator.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/common/service/Validator.java @@ -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; + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index 931495f4a4..912db6b868 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -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."; + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorResponse.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorResponse.java index db4e7c3765..f746d56a37 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorResponse.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorResponse.java @@ -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; } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index 348287030b..5f451dc347 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -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()); + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanApprovedAmountChangedBusinessEvent.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanApprovedAmountChangedBusinessEvent.java new file mode 100644 index 0000000000..c05cf96f6d --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/LoanApprovedAmountChangedBusinessEvent.java @@ -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; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanApprovedAmountHistoryData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanApprovedAmountHistoryData.java new file mode 100644 index 0000000000..8598d6ea8d --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanApprovedAmountHistoryData.java @@ -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; +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanApprovedAmountHistory.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanApprovedAmountHistory.java new file mode 100644 index 0000000000..8e4963b3b0 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanApprovedAmountHistory.java @@ -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; +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanApprovedAmountHistoryRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanApprovedAmountHistoryRepository.java new file mode 100644 index 0000000000..7175b0633c --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanApprovedAmountHistoryRepository.java @@ -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); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanApprovedAmountModificationCommandHandler.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanApprovedAmountModificationCommandHandler.java new file mode 100644 index 0000000000..1be514b868 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanApprovedAmountModificationCommandHandler.java @@ -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); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApprovedAmountValidator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApprovedAmountValidator.java new file mode 100644 index 0000000000..0d61d0805a --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApprovedAmountValidator.java @@ -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); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApprovedAmountWritePlatformService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApprovedAmountWritePlatformService.java new file mode 100644 index 0000000000..5fb2621723 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApprovedAmountWritePlatformService.java @@ -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); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApprovedAmountWritePlatformServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApprovedAmountWritePlatformServiceImpl.java new file mode 100644 index 0000000000..49b01a7f3f --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApprovedAmountWritePlatformServiceImpl.java @@ -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(); + } +} diff --git a/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml b/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml index 0a8b7e2fec..d749153bed 100644 --- a/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml +++ b/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml @@ -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> diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java index f5beeeacee..f4f0e51b92 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java @@ -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); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index 3d4b1cdbc2..6c2252f1ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -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; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java index e75d377929..3c7fd4c9d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java @@ -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())); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApprovedAmountValidatorImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApprovedAmountValidatorImpl.java new file mode 100644 index 0000000000..9ea5b444b3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApprovedAmountValidatorImpl.java @@ -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"); + } + }); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanDisbursementValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanDisbursementValidator.java index 6b57292301..69be1ab519 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanDisbursementValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanDisbursementValidator.java @@ -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()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java index 00716c1ee8..4847176e4f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanTransactionValidatorImpl.java @@ -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()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java index 428f0d4b2b..c7fe4e457e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanDisbursementService.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index 23731b0b61..70866c25c4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -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, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 5e627b4d8d..fa8027a4e8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -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> diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0191_add_LoanApprovedAmountChangedBusinessEvent.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0191_add_LoanApprovedAmountChangedBusinessEvent.xml new file mode 100644 index 0000000000..d58c2255b2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0191_add_LoanApprovedAmountChangedBusinessEvent.xml @@ -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> diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0192_create_loan_approved_amount_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0192_create_loan_approved_amount_history.xml new file mode 100644 index 0000000000..1f0d1b284e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0192_create_loan_approved_amount_history.xml @@ -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> diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index 3347b2162b..697534d2de 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -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)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index dfe969ca41..788b5ea2ce 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -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()); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanModifyApprovedAmountTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanModifyApprovedAmountTests.java new file mode 100644 index 0000000000..93bb797089 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanModifyApprovedAmountTests.java @@ -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")); + }); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java index 5970b58265..86651e3376 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java @@ -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; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java index 8a6c1d382f..d10508569b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java @@ -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,
