FINERACT-2311: Add buydown fees to loan

This commit is contained in:
budaidev
2025-07-01 20:14:43 +02:00
committed by Adam Saghy
parent cb6a15c3d2
commit 98b7328508
23 changed files with 729 additions and 7 deletions
@@ -0,0 +1,264 @@
/**
* 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdTransactions;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.accounting.Account;
import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
/**
* Integration tests for Buy Down Fee functionality in Progressive Loans
*/
@Slf4j
@ExtendWith({ LoanTestLifecycleExtension.class })
public class LoanBuyDownFeeTest extends BaseLoanIntegrationTest {
private Long clientId;
private Long loanId;
// Buy Down Fee accounts for accrual-based accounting
private final Account buyDownExpenseAccount = accountHelper.createExpenseAccount();
private final Account deferredIncomeLiabilityAccount = accountHelper.createLiabilityAccount();
// Additional receivable accounts required for accrual-based accounting
private final Account interestReceivableAccount = accountHelper.createAssetAccount();
private final Account feeReceivableAccount = accountHelper.createAssetAccount();
private final Account penaltyReceivableAccount = accountHelper.createAssetAccount();
@BeforeEach
public void beforeEach() {
runAt("01 September 2024", () -> {
clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
final PostLoanProductsResponse loanProductsResponse = loanProductHelper
.createLoanProduct(createProgressiveLoanProductWithBuyDownFee());
// Apply for the loan with proper progressive loan settings
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applyLP2ProgressiveLoanRequest(clientId,
loanProductsResponse.getResourceId(), "01 September 2024", 1000.0, 10.0, 12, null));
loanId = postLoansResponse.getLoanId();
loanTransactionHelper.approveLoan(loanId, approveLoanRequest(1000.0, "01 September 2024"));
disburseLoan(loanId, BigDecimal.valueOf(1000.0), "01 September 2024");
});
}
@Test
public void testBuyDownFeeOnProgressiveLoan() {
runAt("02 September 2024", () -> {
// Verify loan product has buy down fee enabled
final GetLoansLoanIdResponse loanDetailsBeforeTransaction = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanDetailsBeforeTransaction);
log.info("Loan Product: {}", loanDetailsBeforeTransaction.getLoanProductName());
// Create buy down fee transaction
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 500.0, "02 September 2024");
assertNotNull(buyDownFeeTransactionId);
// Verify transaction was created in loan details
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanDetails);
// Find the buy down fee transaction
boolean buyDownFeeFound = false;
for (GetLoansLoanIdTransactions transaction : loanDetails.getTransactions()) {
if (transaction.getType() != null && transaction.getType().getId() != null && transaction.getType().getId().equals(40L)) {
buyDownFeeFound = true;
assertEquals(0, BigDecimal.valueOf(500.0).compareTo(transaction.getAmount()));
assertEquals(Long.valueOf(40), transaction.getType().getId());
assertEquals("Buy Down Fee", transaction.getType().getValue());
break;
}
}
assertTrue(buyDownFeeFound, "Buy down fee transaction should be found in loan transactions");
});
}
@Test
public void testBuyDownFeeWithNote() {
runAt("03 September 2024", () -> {
String externalId = UUID.randomUUID().toString();
String noteText = "Buy Down Fee - Test Note";
PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId,
new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("03 September 2024").locale("en")
.transactionAmount(250.0).externalId(externalId).note(noteText));
assertNotNull(response.getResourceId());
// Verify transaction details
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
GetLoansLoanIdTransactions buyDownFeeTransaction = loanDetails.getTransactions().stream()
.filter(t -> t.getType() != null && t.getType().getId() != null && t.getType().getId().equals(40L))
.filter(t -> externalId.equals(t.getExternalId())).findFirst().orElse(null);
assertNotNull(buyDownFeeTransaction, "Buy down fee transaction should exist");
assertEquals(0, BigDecimal.valueOf(250.0).compareTo(buyDownFeeTransaction.getAmount()));
assertEquals(externalId, buyDownFeeTransaction.getExternalId());
});
}
@Test
public void testMultipleBuyDownFees() {
runAt("04 September 2024", () -> {
// Add first buy down fee
Long firstBuyDownFeeId = addBuyDownFeeForLoan(loanId, 200.0, "04 September 2024");
// Add second buy down fee
Long secondBuyDownFeeId = addBuyDownFeeForLoan(loanId, 150.0, "04 September 2024");
assertNotNull(firstBuyDownFeeId);
assertNotNull(secondBuyDownFeeId);
// Verify both transactions exist
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
long buyDownFeeCount = loanDetails.getTransactions().stream()
.filter(t -> t.getType() != null && t.getType().getId() != null && t.getType().getId().equals(40L)).count();
assertEquals(2, buyDownFeeCount, "Should have 2 buy down fee transactions");
});
}
@Test
public void testBuyDownFeeAccountingEntries() {
runAt("04 September 2024", () -> {
// Add Buy Down fee transaction
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 250.0, "04 September 2024");
assertNotNull(buyDownFeeTransactionId);
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
GetLoansLoanIdTransactions buyDownFeeTransaction = loanDetails.getTransactions().stream()
.filter(t -> t.getType() != null && t.getType().getId() != null && t.getType().getId().equals(40L))
.filter(t -> buyDownFeeTransactionId.equals(t.getId())).findFirst().orElse(null);
assertNotNull(buyDownFeeTransaction, "Buy down fee transaction should exist");
assertEquals(0, BigDecimal.valueOf(250.0).compareTo(buyDownFeeTransaction.getAmount()));
verifyTRJournalEntries(buyDownFeeTransactionId, debit(buyDownExpenseAccount, 250.0), // DR: Buy Down Expense
credit(deferredIncomeLiabilityAccount, 250.0) // CR: Deferred Income Liability
);
log.info("Buy Down Fee transaction created successfully (accounting validation pending client model regeneration)");
});
}
@Test
public void testBuyDownFeeValidation() {
runAt("05 September 2024", () -> {
// Test with negative amount (should fail)
try {
PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId,
new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("05 September 2024")
.locale("en").transactionAmount(-100.0).note("Invalid negative amount"));
assertTrue(false, "Buy down fee with negative amount should have failed");
} catch (Exception e) {
// Expected: validation should prevent negative amounts
log.info("Expected validation error for negative amount: {}", e.getMessage());
}
// Test with zero amount (should fail)
try {
PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId,
new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("05 September 2024")
.locale("en").transactionAmount(0.0).note("Invalid zero amount"));
assertTrue(false, "Buy down fee with zero amount should have failed");
} catch (Exception e) {
// Expected: validation should prevent zero amounts
log.info("Expected validation error for zero amount: {}", e.getMessage());
}
});
}
/**
* Creates a progressive loan product with buy down fee enabled
*/
private PostLoanProductsRequest createProgressiveLoanProductWithBuyDownFee() {
// Create a progressive loan product with accrual-based accounting and proper GL mappings
return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("BUY_DOWN_FEE_PROGRESSIVE_", 6))
.shortName(Utils.uniqueRandomStringGenerator("", 4)).description("Progressive loan product with buy down fee enabled")
.includeInBorrowerCycle(false).useBorrowerCycle(false).currencyCode("USD").digitsAfterDecimal(2).principal(1000.0)
.minPrincipal(100.0).maxPrincipal(10000.0).numberOfRepayments(12).minNumberOfRepayments(6).maxNumberOfRepayments(24)
.repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L).interestRatePerPeriod(10.0)
.minInterestRatePerPeriod(0.0).maxInterestRatePerPeriod(120.0).interestRateFrequencyType(InterestRateFrequencyType.YEARS)
.amortizationType(AmortizationType.EQUAL_INSTALLMENTS).interestType(InterestType.DECLINING_BALANCE)
.interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).allowPartialPeriodInterestCalcualtion(false)
.transactionProcessingStrategyCode("advanced-payment-allocation-strategy")
.paymentAllocation(List.of(createDefaultPaymentAllocation("NEXT_INSTALLMENT"))).creditAllocation(List.of())
.daysInMonthType(30).daysInYearType(360).isInterestRecalculationEnabled(false).accountingRule(3) // Accrual-based
// accounting
// GL Account Mappings for Accrual-Based Accounting
.fundSourceAccountId(fundSource.getAccountID().longValue())
.loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())
.transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())
.interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())
.incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())
.incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue())
.incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())
.writeOffAccountId(writtenOffAccount.getAccountID().longValue())
.overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())
// Receivable accounts required for accrual-based accounting
.receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue())
.receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue())
.receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue())
.buyDownExpenseAccountId(buyDownExpenseAccount.getAccountID().longValue())
.incomeFromBuyDownAccountId(feeIncomeAccount.getAccountID().longValue())
.deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue()).loanScheduleType("PROGRESSIVE")
.loanScheduleProcessingType("HORIZONTAL").enableBuyDownFee(true)
.buyDownFeeCalculationType(PostLoanProductsRequest.BuyDownFeeCalculationTypeEnum.FLAT)
.buyDownFeeStrategy(PostLoanProductsRequest.BuyDownFeeStrategyEnum.EQUAL_AMORTIZATION)
.buyDownFeeIncomeType(PostLoanProductsRequest.BuyDownFeeIncomeTypeEnum.FEE).locale("en").dateFormat("dd MMMM yyyy");
}
/**
* Helper method to add buy down fee for a loan
*
* @param loanId
* the ID of the loan to add the buy down fee to
* @param amount
* the amount of the buy down fee
* @param date
* the transaction date in format specified by DATETIME_PATTERN
* @return the ID of the created buy down fee transaction
*/
private Long addBuyDownFeeForLoan(Long loanId, Double amount, String date) {
String buyDownFeeExternalId = UUID.randomUUID().toString();
PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId,
new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate(date).locale("en")
.transactionAmount(amount).externalId(buyDownFeeExternalId).note("Buy Down Fee Transaction"));
return response.getResourceId();
}
}
@@ -166,6 +166,8 @@ public class LoanProductTestBuilder {
private List<String> supportedInterestRefundTypes = null;
private String chargeOffBehaviour;
private boolean interestRecognitionOnDisbursementDate = false;
private Boolean enableBuyDownFee = false;
private String buyDownFeeCalculationType;
public String build() {
final HashMap<String, Object> map = build(null, null);
@@ -341,6 +343,14 @@ public class LoanProductTestBuilder {
map.put("chargeOffBehaviour", chargeOffBehaviour);
}
if (this.enableBuyDownFee != null) {
map.put("enableBuyDownFee", this.enableBuyDownFee);
}
if (this.buyDownFeeCalculationType != null) {
map.put("buyDownFeeCalculationType", this.buyDownFeeCalculationType);
}
return map;
}
@@ -906,4 +916,13 @@ public class LoanProductTestBuilder {
}
}
public LoanProductTestBuilder withEnableBuyDownFee(final Boolean enableBuyDownFee) {
this.enableBuyDownFee = enableBuyDownFee;
return this;
}
public LoanProductTestBuilder withBuyDownFeeCalculationType(final String buyDownFeeCalculationType) {
this.buyDownFeeCalculationType = buyDownFeeCalculationType;
return this;
}
}
@@ -3049,6 +3049,10 @@ public class LoanTransactionHelper {
return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, request, "downPayment"));
}
public PostLoansLoanIdTransactionsResponse makeLoanBuyDownFee(Long loanId, PostLoansLoanIdTransactionsRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, request, "buyDownFee"));
}
public List<AdvancedPaymentData> getAdvancedPaymentAllocationRules(final Integer loanId) {
return Calls.ok(FineractClientHelper.getFineractClient().legacy.getAdvancedPaymentAllocationRulesOfLoan(loanId.longValue()));
}