FINERACT-2308: Allow approval / disbursal above loan applied amount for multidisbursal progressive loan that does expect tranches

This commit is contained in:
mariiaKraievska
2025-08-01 15:44:43 +02:00
committed by Adam Saghy
parent cb4c66302a
commit 68bfd7a3f8
8 changed files with 210 additions and 11 deletions
@@ -132,6 +132,7 @@ public enum DefaultLoanProduct implements LoanProduct {
LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_CAPITALIZED_INCOME, //
LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_BUYDOWN_FEES, //
LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_BUYDOWN_FEES_CHARGE_OFF_REASON, //
LP2_PROGRESSIVE_ADV_PYMNT_INTEREST_RECALC_360_30_MULTIDISB_OVER_APPLIED_EXPECTED_TRANCHES, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME_ADJ_CUSTOM_ALLOC, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_CAPITALIZED_INCOME_FEE, //
@@ -3426,6 +3426,43 @@ public class LoanProductGlobalInitializerStep implements FineractGlobalInitializ
TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_MULTIDISBURSAL_EXPECTS_TRANCHES,
responseLoanProductMultidisbursalExpectTranches);
// 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
// allow approved/disbursed amount over applied amount is enabled with percentage
// multidisbursal loan that expects tranches
// type
final String name131 = DefaultLoanProduct.LP2_PROGRESSIVE_ADV_PYMNT_INTEREST_RECALC_360_30_MULTIDISB_OVER_APPLIED_EXPECTED_TRANCHES
.getName();
final PostLoanProductsRequest loanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedExpectTranches = loanProductsRequestFactory
.defaultLoanProductsRequestLP2EmiCapitalizedIncome()//
.name(name131)//
.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(false)//
.maxTrancheCount(10)//
.outstandingLoanBalance(10000.0);//
final Response<PostLoanProductsResponse> responseLoanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedExpectTranches = loanProductsApi
.createLoanProduct(loanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedExpectTranches)
.execute();
TestContext.INSTANCE.set(
TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_INTEREST_RECALC_360_30_MULTIDISB_OVER_APPLIED_EXPECTED_TRANCHES,
responseLoanProductsRequestLP2ProgressiveAdvPymnt36030InterestRecalcMultidisbApprovedOverAppliedExpectTranches);
}
public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule,
@@ -255,4 +255,5 @@ public abstract class TestContextKey {
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_PERCENT_AMOUNT_INTEREST_CHARGES = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyInstallmentFeeAmountPlusPercentageInterestCharges";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_ALL_CHARGES = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyInstallmentFeeAllCharges";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_INSTALLMENT_FEE_FLAT_INTEREST_CHARGES_TRANCHE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyInstallmentFeeFlatPlusPercentageInterestChargesMultidisbursal";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_INTEREST_RECALC_360_30_MULTIDISB_OVER_APPLIED_EXPECTED_TRANCHES = "loanProductCreateResponseLP2ProgressiveAdvancedPaymentInterestRecalculationMultidisbursalApprovedOverAppliedAmountExpectedTransches";
}
@@ -251,3 +251,17 @@ Feature: LoanProduct
Then Loan Product response contains Buy Down Fees flag "false"
Then Loan Details response contains Buy Down Fees flag "false"
@TestRailId:C3884
Scenario: As a user I would like to verify multi-disburse loan product with over-applied amount and expected tranches can be created
When Admin sets the business date to "1 January 2024"
And Admin creates a client with random data
When Admin creates a fully customized loan with 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 |
| LP2_PROGRESSIVE_ADV_PYMNT_INTEREST_RECALC_360_30_MULTIDISB_OVER_APPLIED_EXPECTED_TRANCHES | 01 January 2024 | 1000 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 700.0 | 02 January 2024 | 300.0 |
And Admin successfully approves the loan on "1 January 2024" with "1000" amount and expected disbursement date on "1 January 2024"
And Admin successfully disburse the loan on "1 January 2024" with "1000" EUR transaction amount
Then Loan status will be "ACTIVE"
When Admin sets the business date to "2 January 2024"
And Admin successfully disburse the loan on "2 January 2024" with "300" EUR transaction amount
And Admin adds capitalized income with "AUTOPAY" payment type to the loan on "02 January 2024" with "200" EUR transaction amount
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.delinquency.service;
import static org.apache.fineract.portfolio.loanaccount.domain.Loan.EARLIEST_UNPAID_DATE;
import static org.apache.fineract.portfolio.loanaccount.domain.Loan.NEXT_UNPAID_DUE_DATE;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Comparator;
@@ -148,7 +149,10 @@ public class DelinquencyReadPlatformServiceImpl implements DelinquencyReadPlatfo
// Overpaid
// loans
collectionData = loanDelinquencyDomainService.getOverdueCollectionData(loan, effectiveDelinquencyList);
collectionData.setAvailableDisbursementAmount(loan.getApprovedPrincipal().subtract(loan.getDisbursedAmount()));
// Ensure availableDisbursementAmount is never negative
final BigDecimal availableAmount = loan.getApprovedPrincipal().subtract(loan.getDisbursedAmount());
collectionData
.setAvailableDisbursementAmount(availableAmount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : availableAmount);
collectionData.setNextPaymentDueDate(possibleNextRepaymentDate(nextPaymentDueDateConfig, loan));
final LoanTransaction lastPayment = loan.getLastPaymentTransaction();
@@ -414,14 +414,6 @@ public class LoanProduct extends AbstractPersistableCustom<Long> {
}
}
if (this.allowApprovedDisbursedAmountsOverApplied) {
if (this.isMultiDisburseLoan() && !this.disallowExpectedDisbursements) {
throw new LoanProductGeneralRuleException(
"disallowExpectedDisbursements.not.set.allowApprovedDisbursedAmountsOverApplied.cant.be.set",
"Disallow Expected Disbursals Not Set - Allow Approved / Disbursed Amounts Over Applied Can't Be Set");
}
}
if (this.overAppliedCalculationType == null || this.overAppliedCalculationType.isEmpty()) {
if (this.allowApprovedDisbursedAmountsOverApplied) {
throw new LoanProductGeneralRuleException(
@@ -28,10 +28,8 @@ Feature: Loan Product Validation
Examples:
| allowMultipleDisbursal | disallowExpectedDisbursements | allowApprovedDisbursedAmountsOverApplied | overAppliedCalculationType | overAppliedNumber | exception | template |
| false | true | false | - | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.allowMultipleDisbursals.not.set.disallowExpectedDisbursements.cant.be.set |
| true | false | true | - | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.disallowExpectedDisbursements.not.set.allowApprovedDisbursedAmountsOverApplied.cant.be.set |
| true | true | true | - | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.allowApprovedDisbursedAmountsOverApplied.is.set.overAppliedCalculationType.is.mandatory |
| true | true | true | - | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.allowApprovedDisbursedAmountsOverApplied.is.set.overAppliedCalculationType.is.mandatory |
| true | false | true | - | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.disallowExpectedDisbursements.not.set.allowApprovedDisbursedAmountsOverApplied.cant.be.set |
| true | true | true | notflat | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.overAppliedCalculationType.must.be.percentage.or.flat |
| true | true | false | flat | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.allowApprovedDisbursedAmountsOverApplied.is.not.set.overAppliedCalculationType.cant.be.entered |
| true | true | true | flat | -1 | org.apache.fineract.portfolio.loanproduct.exception.LoanProductGeneralRuleException | error.msg.allowApprovedDisbursedAmountsOverApplied.is.set.overAppliedNumber.is.mandatory |
@@ -0,0 +1,152 @@
/**
* 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.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import org.apache.fineract.client.models.GetLoanProductsProductIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansDisbursementData;
import org.apache.fineract.client.models.PutLoanProductsProductIdRequest;
import org.apache.fineract.client.models.PutLoanProductsProductIdResponse;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.junit.jupiter.api.Test;
public class LoanProductOverAppliedAmountTest extends BaseLoanIntegrationTest {
@Test
public void testCreateMultiDisburseLoanProductWithOverAppliedAmountAndExpectedTranches() {
runAt("01 January 2024", () -> {
// Create Loan Product with multi-disburse, expected tranches, and over-applied amount
final PostLoanProductsRequest loanProductRequest = create4IProgressive().multiDisburseLoan(true).maxTrancheCount(5)
.outstandingLoanBalance(10000.0).disallowExpectedDisbursements(false) // Expected tranches enabled
.allowApprovedDisbursedAmountsOverApplied(true).overAppliedCalculationType("percentage").overAppliedNumber(50);
// This should not throw an exception
final PostLoanProductsResponse loanProductResponse = assertDoesNotThrow(
() -> loanProductHelper.createLoanProduct(loanProductRequest));
assertNotNull(loanProductResponse);
assertNotNull(loanProductResponse.getResourceId());
// Retrieve the created loan product to verify settings
final GetLoanProductsProductIdResponse retrievedProduct = loanProductHelper
.retrieveLoanProductById(loanProductResponse.getResourceId());
// Verify the loan product was created with correct settings
assertEquals(true, retrievedProduct.getMultiDisburseLoan());
assertEquals(false, retrievedProduct.getDisallowExpectedDisbursements());
assertEquals(true, retrievedProduct.getAllowApprovedDisbursedAmountsOverApplied());
assertEquals("percentage", retrievedProduct.getOverAppliedCalculationType());
});
}
@Test
public void testModifyMultiDisburseLoanProductWithOverAppliedAmountAndExpectedTranches() {
runAt("01 January 2024", () -> {
// Create initial loan product without over-applied amount
final PostLoanProductsRequest initialLoanProductRequest = create4IProgressive().multiDisburseLoan(true).maxTrancheCount(5)
.outstandingLoanBalance(10000.0).disallowExpectedDisbursements(false).allowApprovedDisbursedAmountsOverApplied(false)
.overAppliedCalculationType(null).overAppliedNumber(null);
final PostLoanProductsResponse initialLoanProductResponse = loanProductHelper.createLoanProduct(initialLoanProductRequest);
final Long loanProductId = initialLoanProductResponse.getResourceId();
// Modify loan product to enable over-applied amount
final PutLoanProductsProductIdRequest modifyRequest = new PutLoanProductsProductIdRequest()
.allowApprovedDisbursedAmountsOverApplied(true).overAppliedCalculationType("flat").overAppliedNumber(200).locale("en");
final PutLoanProductsProductIdResponse modifyResponse = assertDoesNotThrow(
() -> loanProductHelper.updateLoanProductById(loanProductId, modifyRequest));
assertNotNull(modifyResponse);
// Retrieve the updated loan product to verify settings
final GetLoanProductsProductIdResponse retrievedProduct = loanProductHelper.retrieveLoanProductById(loanProductId);
assertEquals(true, retrievedProduct.getMultiDisburseLoan());
assertEquals(false, retrievedProduct.getDisallowExpectedDisbursements());
assertEquals(true, retrievedProduct.getAllowApprovedDisbursedAmountsOverApplied());
assertEquals("flat", retrievedProduct.getOverAppliedCalculationType());
});
}
@Test
public void testAvailableDisbursementAmountNotNegativeWhenDisbursedAmountExceedsApprovedAmount() {
runAt("01 January 2024", () -> {
// Create Loan Product with over-applied amount enabled
final PostLoanProductsRequest loanProductRequest = create4IProgressive().multiDisburseLoan(true).maxTrancheCount(5)
.outstandingLoanBalance(10000.0).disallowExpectedDisbursements(false) // Expected tranches enabled
.allowApprovedDisbursedAmountsOverApplied(true).overAppliedCalculationType("percentage").overAppliedNumber(50);
final PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductRequest);
final Long loanProductId = loanProductResponse.getResourceId();
// Create client
final Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
assertNotNull(clientId);
// Create and approve loan with amount 1000
final Long loanId = applyAndApproveProgressiveLoan(clientId, loanProductId, "1 January 2024", 1000.0, 7.0, 6,
request -> request.disbursementData(List.of(new PostLoansDisbursementData().expectedDisbursementDate("1 January 2024")
.principal(BigDecimal.valueOf(1000.0)))));
// Disburse loan with amount 1500 (exceeds approved amount, but allowed due to over-applied setting)
disburseLoan(loanId, BigDecimal.valueOf(1500.0), "01 January 2024");
// Verify loan is active
verifyLoanStatus(loanId, LoanStatus.ACTIVE);
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanDetails);
// Verify that the loan was created and disbursed successfully
assert loanDetails.getStatus() != null;
assertEquals(Boolean.TRUE, loanDetails.getStatus().getActive());
// Verify the amounts
assert loanDetails.getApprovedPrincipal() != null;
assertEquals(BigDecimal.valueOf(1000.0), loanDetails.getApprovedPrincipal().setScale(1, RoundingMode.HALF_UP));
assert loanDetails.getDisbursementDetails() != null;
double disbursementPrincipalSum = loanDetails.getDisbursementDetails().stream().mapToDouble(detail -> {
assert detail.getPrincipal() != null;
return detail.getPrincipal();
}).sum();
assertEquals(1500.0, disbursementPrincipalSum);
// The key test: availableDisbursementAmount should be 0 (not negative)
// since disbursed amount (1500) > approved amount (1000)
assert loanDetails.getDelinquent() != null;
final BigDecimal availableDisbursementAmount = loanDetails.getDelinquent().getAvailableDisbursementAmount();
assertNotNull(availableDisbursementAmount);
assertTrue(availableDisbursementAmount.compareTo(BigDecimal.ZERO) >= 0,
"availableDisbursementAmount should not be negative. Expected >= 0, but was: " + availableDisbursementAmount);
assertEquals(BigDecimal.ZERO, availableDisbursementAmount,
"availableDisbursementAmount should be 0 when disbursed amount exceeds approved amount");
});
}
}