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 a3f2c14c4b6..42f23b6bac9 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 CommandWrapperBuilder makeLoanBuyDownFee(final Long loanId) { 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 eb1f3237f49..c953bb545b2 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.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 baseDataValidator) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder dataValidatorBuilder = new DataValidatorBuilder(dataValidationErrors).resource(resource); - - baseDataValidator.accept(dataValidatorBuilder); + final List 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 baseDataValidator) { + final List 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 getApiParameterErrors(String resource, Consumer baseDataValidator) { + final List 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/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index c302c5f083e..43dbdab2ee3 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -144,6 +144,7 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_APPROVED_OVER_APPLIED_PERCENTAGE_CAPITALIZED_INCOME, // LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_APPROVED_OVER_APPLIED_FLAT_CAPITALIZED_INCOME, // LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_CAPITALIZED_INCOME_ADJ_CUSTOM_ALLOC, // + LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_APPROVED_OVER_APPLIED_CAPITALIZED_INCOME, // LP2_ADV_PYMNT_360_30_INTEREST_RECALC_AUTO_DOWNPAYMENT_ZERO_INTEREST_CHARGE_OFF_ACCRUAL_ACTIVITY, // LP2_ADV_PYMNT_INTEREST_DAILY_INTEREST_RECALC_DAILY_INSTALLMENT_FEE_FLAT_CHARGES, // LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_PERCENT_AMOUNT_CHARGES, // @@ -151,6 +152,7 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_PERCENT_AMOUNT_INTEREST_CHARGES, // LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_ALL_CHARGES, // LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_FLAT_INTEREST_CHARGES_TRANCHE, // + LP1_MULTIDISBURSAL_EXPECTS_TRANCHES, // ; @Override 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 931495f4a40..595abad3ef1 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 static String disburseDateFailure(Integer loanId) { } 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,16 @@ public static String addInstallmentFeeInterestPercentageChargeFailure() { 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: can't.be.greater.than.maximum.applied.loan.amount.calculation."; + } + + public static String updateApprovedLoanLessThanDisbursedPrincipalAndCapitalizedIncomeFailure() { + 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 db4e7c3765a..f746d56a377 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 static ErrorResponse from(Response retrofitResponse) { public static class Error { private String developerMessage; + private List 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/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index a19f9fabbb7..970d25858f4 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -3361,6 +3361,67 @@ public void initialize() throws Exception { TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_BUYDOWN_FEES_CHARGE_OFF_REASON, responseLoanProductsRequestLP2ProgressiveAdvPaymentBuyDownFeesWithChargeOffReason); + // LP2 with progressive loan schedule + horizontal + interest EMI + 360/30 + // + interest recalculation, preClosureInterestCalculationStrategy= till preclose, + // Frequency for recalculate Outstanding Principal: Daily, Frequency Interval for recalculation: 1 + // capitalized income enabled; allow approved/disbursed amount over applied amount is enabled with percentage + // type + final String name129 = DefaultLoanProduct.LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_APPROVED_OVER_APPLIED_CAPITALIZED_INCOME + .getName(); + final PostLoanProductsRequest loanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedCapitalizedIncome = loanProductsRequestFactory + .defaultLoanProductsRequestLP2EmiCapitalizedIncome()// + .name(name129)// + .daysInYearType(DaysInYearType.DAYS360.value)// + .daysInMonthType(DaysInMonthType.DAYS30.value)// + .isInterestRecalculationEnabled(true)// + .preClosureInterestCalculationStrategy(1)// + .rescheduleStrategyMethod(4)// + .interestRecalculationCompoundingMethod(0)// + .recalculationRestFrequencyType(2)// + .recalculationRestFrequencyInterval(1)// + .paymentAllocation(List.of(// + createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), // + createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), // + createPaymentAllocation("MERCHANT_ISSUED_REFUND", "REAMORTIZATION"), // + createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT"))) // + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType(OverAppliedCalculationType.PERCENTAGE.value)// + .overAppliedNumber(50)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0);// + final Response responseLoanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedCapitalizedIncome = loanProductsApi + .createLoanProduct( + loanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedCapitalizedIncome) + .execute(); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_APPROVED_OVER_APPLIED_CAPITALIZED_INCOME, + responseLoanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedCapitalizedIncome); + + // LP1 with new due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest-strategy payment + // strategy and with 12% FLAT interest + // multidisbursal that expects tranche(s) + // (LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT) + String name130 = DefaultLoanProduct.LP1_MULTIDISBURSAL_EXPECTS_TRANCHES.getName(); + PostLoanProductsRequest loanProductsRequestMultidisbursalExpectTranches = loanProductsRequestFactory + // .defaultLoanProductsRequestLP1InterestFlat()// + // .interestType(INTEREST_TYPE_DECLINING_BALANCE)// + .defaultLoanProductsRequestLP1() // + .interestCalculationPeriodType(0)// + // .allowApprovedDisbursedAmountsOverApplied(false)// + .name(name130)// + .transactionProcessingStrategyCode( + TransactionProcessingStrategyCode.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST.value)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(false)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0);// + Response responseLoanProductMultidisbursalExpectTranches = loanProductsApi + .createLoanProduct(loanProductsRequestMultidisbursalExpectTranches).execute(); + TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_MULTIDISBURSAL_EXPECTS_TRANCHES, + responseLoanProductMultidisbursalExpectTranches); + } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, 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 348287030b6..8e82eb3d104 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_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.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 void createFullyCustomizedLoanWithDisbursementsDetails(final DataTable ta 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> 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> data = table.asLists(); @@ -3688,6 +3697,27 @@ public void createFullyCustomizedLoanWithExpectedTrancheDisbursementsDetails(fin createFullyCustomizedLoanExpectsTrancheDisbursementDetails(loanData, disbursementDetail); } + public void createFullyCustomizedLoanWithThreeExpectedTrancheDisbursementsDetails(final List 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 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 loanData, List disbursementDetail) throws IOException { final String loanProduct = loanData.get(0); @@ -4859,4 +4889,99 @@ public void adminAddsBuyDownFeesAdjustmentToTheLoan(final String transactionPaym 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 loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final long loanId = loanResponse.body().getLoanId(); + final Response loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute(); + ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse); + + final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN) + .amount(new BigDecimal(amount)); + + final Response 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 loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final long loanId = loanResponse.body().getLoanId(); + final Response loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute(); + ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse); + + final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN) + .amount(new BigDecimal(amount)); + + final Response 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 higher principal amount on loan") + public void updateLoanApprovedAmountForbiddenHigherPrincipalAmountOnLoan(final String amount) throws IOException { + final Response loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final long loanId = loanResponse.body().getLoanId(); + final Response loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute(); + ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse); + + final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN) + .amount(new BigDecimal(amount)); + + final Response 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.updateApprovedLoanLessThanDisbursedPrincipalAndCapitalizedIncomeFailure()); + } + + @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 loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final long loanId = loanResponse.body().getLoanId(); + final Response loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute(); + ErrorHelper.checkSuccessfulApiCall(loanDetailsResponse); + + final PutLoansApprovedAmountRequest modifyLoanApprovedAmountRequest = new PutLoansApprovedAmountRequest().locale(LOCALE_EN) + .amount(new BigDecimal(amount)); + + final Response 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.updateApprovedLoanLessMinAllowedAmountFailure()); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 789b9a5556e..9b7c554cc95 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -161,6 +161,7 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_OVER_APPLIED_PERCENTAGE_CAPITALIZED_INCOME = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyMultidisbursalApprovedOVerAppliedPercentageCapitalizedIncome"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_OVER_APPLIED_FLAT_CAPITALIZED_INCOME = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyMultidisbursalApprovedOVerAppliedFlatCapitalizedIncome"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_APPROVED_OVER_APPLIED_PERCENTAGE_CAPITALIZED_INCOME = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyApprovedOVerAppliedAmountPercentageCapitalizedIncome"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_APPROVED_OVER_APPLIED_CAPITALIZED_INCOME = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalcDailyMultidisbursalApprovedOVerAppliedAmountPercentageCapitalizedIncome"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_APPROVED_OVER_APPLIED_FLAT_CAPITALIZED_INCOME = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyApprovedOVerAppliedAmountFlatCapitalizedIncome"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME = "loanProductCreateResponseLP2ProgressiveAdvPayment36030InterestRecalcCapitalizedIncome"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME_FEE = "loanProductCreateResponseLP2ProgressiveAdvPayment36030InterestRecalcCapitalizedIncomeFee"; @@ -176,6 +177,7 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_IR_DAILY_TILL_REST_FREQUENCY_DATE_LAST_INSTALLMENT = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyTillRestFrequencyDateLastInstallment"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_REFUND_INTEREST_RECALC_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRefundInterestRecalculationAccrualActivity"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_REFUND_INTEREST_RECALC_DOWNPAYMENT_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRefundInterestRecalculatioDownpaymentnAccrualActivity"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_MULTIDISBURSAL_EXPECTS_TRANCHES = "loanProductCreateResponseLP1MultidisbursalThatExpectTranches"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_CREATE_RESPONSE = "ChargeForLoanPercentLateCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_AMOUNT_PLUS_INTEREST_CREATE_RESPONSE = "ChargeForLoanPercentLateAmountPlusInterestCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_PROCESSING_CREATE_RESPONSE = "ChargeForLoanPercentProcessingCreateResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature index fa2a9ad0941..c0044f3fa9a 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature @@ -7247,3 +7247,164 @@ Feature: Loan | 08 August 2025 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | false | | 08 August 2025 | Accrual Activity | 6.22 | 0.0 | 3.42 | 0.0 | 2.8 | 0.0 | false | false | | 15 August 2025 | Repayment | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | false | + + @TestRailId:C3858 + Scenario: Verify update approved amount for progressive loan - UC1 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_TILL_PRECLOSE_PMT_ALLOC_1 | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + Then Update loan approved amount is forbidden with amount "0" due to min allowed amount + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + + @TestRailId:C3859 + Scenario: Verify update approved amount after undo disbursement for single disb progressive loan - UC3 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_TILL_PRECLOSE | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + And Admin successfully disburse the loan on "1 January 2025" with "100" EUR transaction amount + Then Update loan approved amount with new amount "600" value + When Admin successfully undo disbursal + Then Admin fails to disburse the loan on "1 January 2025" with "700" EUR transaction amount due to exceed approved amount + + @TestRailId:C3860 + Scenario: Verify update approved amount with approved over applied amount for progressive multidisbursal loan with percentage overAppliedCalculationType - UC4 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_APPROVED_OVER_APPLIED_CAPITALIZED_INCOME | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + And Admin successfully disburse the loan on "1 January 2025" with "1100" EUR transaction amount + Then Update loan approved amount is forbidden with amount "1600" due to exceed applied amount + Then Update loan approved amount with new amount "1300" value + And Admin successfully disburse the loan on "1 January 2025" with "400" EUR transaction amount + + @TestRailId:C3861 + Scenario: Verify update approved amount with approved over applied amount and capitalized income for progressive loan with percentage overAppliedCalculationType - UC8_1 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_APPROVED_OVER_APPLIED_PERCENTAGE_CAPITALIZED_INCOME | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + And Admin successfully disburse the loan on "1 January 2025" with "1100" EUR transaction amount + Then Update loan approved amount is forbidden with amount "1600" due to exceed applied amount + Then Update loan approved amount with new amount "1400" value + And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "1 January 2025" with "400" EUR transaction amount + + @TestRailId:C3862 + Scenario: Verify update approved amount with capitalized income for progressive loan - UC8_2 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2025" with "900" amount and expected disbursement date on "1 January 2025" + And Admin successfully disburse the loan on "1 January 2025" with "600" EUR transaction amount + Then Update loan approved amount is forbidden with amount "500" due to higher principal amount on loan + And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "1 January 2025" with "200" EUR transaction amount + Then Update loan approved amount with new amount "1000" value + And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "1 January 2025" with "200" EUR transaction amount + + @TestRailId:C3863 + Scenario: Verify update approved amount with capitalized income for progressive multidisbursal loan - UC8_3 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_RECALC_EMI_360_30_MULTIDISB_CAPITALIZED_INCOME_ADJ_CUSTOM_ALLOC | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2025" with "900" amount and expected disbursement date on "1 January 2025" + And Admin successfully disburse the loan on "1 January 2025" with "600" EUR transaction amount + Then Update loan approved amount is forbidden with amount "500" due to higher principal amount on loan + And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "1 January 2025" with "200" EUR transaction amount + Then Update loan approved amount with new amount "1000" value + And Admin successfully disburse the loan on "1 January 2025" with "200" EUR transaction amount + + @TestRailId:C3864 + Scenario: Verify update approved amount before disbursement for single disb cumulative loan - UC5_1 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP1 | 1 January 2025 | 1000 | 0 | DECLINING_BALANCE | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + Then Update loan approved amount with new amount "900" value + And Admin successfully disburse the loan on "1 January 2025" with "100" EUR transaction amount + + @TestRailId:C3865 + Scenario: Verify update approved amount before disbursement for single disb progressive loan - UC5_2 + When Admin sets the business date to "1 January 2025" + And Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_TILL_PRECLOSE | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "1 January 2025" with "1000" amount and expected disbursement date on "1 January 2025" + Then Update loan approved amount with new amount "900" value + And Admin successfully disburse the loan on "1 January 2025" with "100" EUR transaction amount + + @TestRailId:C3866 + Scenario: Verify approved amount change for progressive multidisbursal loan that doesn't expect tranches - UC6 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "200" EUR transaction amount + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 200.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 167.15 | 32.85 | 1.17 | 0.0 | 0.0 | 34.02 | 0.0 | 0.0 | 0.0 | 34.02 | + | 2 | 28 | 01 March 2025 | | 134.11 | 33.04 | 0.98 | 0.0 | 0.0 | 34.02 | 0.0 | 0.0 | 0.0 | 34.02 | + | 3 | 31 | 01 April 2025 | | 100.87 | 33.24 | 0.78 | 0.0 | 0.0 | 34.02 | 0.0 | 0.0 | 0.0 | 34.02 | + | 4 | 30 | 01 May 2025 | | 67.44 | 33.43 | 0.59 | 0.0 | 0.0 | 34.02 | 0.0 | 0.0 | 0.0 | 34.02 | + | 5 | 31 | 01 June 2025 | | 33.81 | 33.63 | 0.39 | 0.0 | 0.0 | 34.02 | 0.0 | 0.0 | 0.0 | 34.02 | + | 6 | 30 | 01 July 2025 | | 0.0 | 33.81 | 0.2 | 0.0 | 0.0 | 34.01 | 0.0 | 0.0 | 0.0 | 34.01 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 4.11 | 0.0 | 0.0 | 204.11 | 0.0 | 0.0 | 0.0 | 204.11 | + Then Update loan approved amount with new amount "600" value + When Admin successfully disburse the loan on "01 January 2025" with "400" EUR transaction amount + Then Update loan approved amount is forbidden with amount "500" due to higher principal amount on loan + + @TestRailId:C3867 + Scenario: Verify approved amount change with lower vallue for progressive multidisbursal loan that expects two tranches - UC7_1 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with three expected disbursements details and following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | 1st_tranche_disb_expected_date | 1st_tranche_disb_principal | 2nd_tranche_disb_expected_date | 2nd_tranche_disb_principal | 3rd_tranche_disb_expected_date | 3rd_tranche_disb_principal | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_EXPECT_TRANCHE | 01 January 2025 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 01 January 2025 | 300.0 | 02 January 2025 | 200.0 | 03 January 2025 | 500.0 | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | | 300.0 | | + | 02 January 2025 | | 200.0 | | + | 03 January 2025 | | 500.0 | | +# --- disbursement - 1 January, 2025 --- + When Admin successfully disburse the loan on "01 January 2025" with "300" EUR transaction amount + When Admin sets the business date to "03 January 2025" + Then Update loan approved amount with new amount "800" value + + @TestRailId:C3868 + Scenario: Verify approved amount change with greater value for progressive multidisbursal loan that expects two tranches - UC7_2 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with three expected disbursements details and following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | 1st_tranche_disb_expected_date | 1st_tranche_disb_principal | 2nd_tranche_disb_expected_date | 2nd_tranche_disb_principal | 3rd_tranche_disb_expected_date | 3rd_tranche_disb_principal | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_EXPECT_TRANCHE | 01 January 2025 | 1200 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 01 January 2025 | 300.0 | 02 January 2025 | 200.0 | 03 January 2025 | 500.0 | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | | 300.0 | | + | 02 January 2025 | | 200.0 | | + | 03 January 2025 | | 500.0 | | +# --- disbursement - 1 January, 2025 --- + When Admin successfully disburse the loan on "01 January 2025" with "300" EUR transaction amount + When Admin sets the business date to "03 January 2025" + Then Update loan approved amount with new amount "1200" value 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 00000000000..c05cf96f6de --- /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 00000000000..8598d6ea8dc --- /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 00000000000..8e4963b3b0e --- /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 { + + @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 00000000000..7175b0633c5 --- /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, JpaSpecificationExecutor { + + @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 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 00000000000..1be514b868b --- /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 00000000000..0d61d0805a7 --- /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 00000000000..5fb26217230 --- /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 00000000000..49b01a7f3fb --- /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 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 0a8b7e2fecd..d749153bedf 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 @@ org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTag org.apache.fineract.portfolio.loanaccount.domain.GroupLoanIndividualMonitoringAccount org.apache.fineract.portfolio.loanaccount.domain.Loan + org.apache.fineract.portfolio.loanaccount.domain.LoanApprovedAmountHistory org.apache.fineract.portfolio.loanaccount.domain.LoanCharge org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement 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 f5beeeacee5..f4f0e51b929 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.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.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 String createLoanDelinquencyAction( 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 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 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 @@ private String createLoanDelinquencyAction(Long loanId, ExternalId loanExternalI 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 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 3d4b1cdbc25..6c2252f1ead 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 @@ private PostLoansLoanIdChanges() {} @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 e75d377929b..3c7fd4c9d58 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 @@ private void compareApprovedToProposedPrincipal(Loan loan, BigDecimal approvedLo 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 00000000000..9ea5b444b3d --- /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 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 supportedParameters = new HashSet<>( + Arrays.asList(LoanApiConstants.amountParameterName, LoanApiConstants.localeParameterName)); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + final Type typeOfMap = new TypeToken>() {}.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 6b57292301a..69be1ab5191 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 void compareDisbursedToApprovedOrProposedPrincipal(final Loan loan, final } 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 00716c1ee81..4847176e4fe 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 void validateDisbursement(JsonCommand command, boolean isAccountTransfer, 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 428f0d4b2ba..c7fe4e457e3 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 Money adjustDisburseAmount(final Loan loan, @NotNull final JsonCommand co } 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 23731b0b610..70866c25c45 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 LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext 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 5e627b4d8d1..fa8027a4e89 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 @@ + + 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 00000000000..d58c2255b2e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0191_add_LoanApprovedAmountChangedBusinessEvent.xml @@ -0,0 +1,31 @@ + + + + + + + + + + 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 00000000000..1f0d1b284ed --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0192_create_loan_approved_amount_history.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 3347b2162bd..697534d2de2 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 void givenAllConfigurationWhenValidatedThenValidationSuccessful() throws "LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent", "LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent", "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent", - "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"); + "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent", "LoanApprovedAmountChangedBusinessEvent"); List tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); @@ -206,7 +206,7 @@ public void givenMissingEventConfigurationWhenValidatedThenThrowException() thro "LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent", "LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent", "LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent", - "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent"); + "LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent", "LoanApprovedAmountChangedBusinessEvent"); List 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 dfe969ca411..788b5ea2ce6 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.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.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 @@ protected List getPointInTimeData(List loanIds, Strin 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 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 00000000000..93bb7970899 --- /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 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 5970b582659..86651e3376f 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 static ArrayList> getDefaultExternalEventConfiguratio loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent.put("enabled", false); defaults.add(loanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent); + Map 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 8a6c1d382f6..d10508569b1 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.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 void afterEach(ExtensionContext context) { 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,