FINERACT-2421: added e2e test for verifying reprocess Interest Refund txn amount if no txn was changed

This commit is contained in:
Rustam Zeinalov
2026-01-13 10:05:19 +01:00
committed by Adam Saghy
parent 6df78f64c0
commit 3e197e367b
7 changed files with 114 additions and 23 deletions
@@ -87,6 +87,7 @@ public enum DefaultLoanProduct implements LoanProduct {
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_ACTUAL_ACTUAL_INTEREST_REFUND, //
LP1_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, //
LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, //
LP2_ADV_PMT_ALLOC_ACTUAL_ACTUAL_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_TILL_PRECLOSE_WHOLE_TERM, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_IR_DAILY_TILL_PRECLOSE_LAST_INSTALLMENT_STRATEGY, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_ACTUAL_ACTUAL_INTEREST_REFUND_INTEREST_RECALCULATION, //
@@ -4231,6 +4231,27 @@ public class LoanProductGlobalInitializerStep implements FineractGlobalInitializ
TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_CHARGEBACK,
responseLoanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseChargeback);
// LP2 + interest recalculation + advanced custom payment allocation + progressive loan schedule + horizontal
// (LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL)
String name152 = DefaultLoanProduct.LP2_ADV_PMT_ALLOC_ACTUAL_ACTUAL_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL.getName();
PostLoanProductsRequest loanProductsRequestAdvPaymentAllocationActualActualProgressiveLoanSchedule = loanProductsRequestFactory
.defaultLoanProductsRequestLP2InterestDailyRecalculation()//
.name(name152)//
.supportedInterestRefundTypes(Arrays.asList("MERCHANT_ISSUED_REFUND", "PAYOUT_REFUND"))//
.enableAccrualActivityPosting(true) //
.daysInYearType(DaysInYearType.ACTUAL.value)//
.daysInMonthType(DaysInMonthType.ACTUAL.value)//
.paymentAllocation(List.of(//
createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), //
createPaymentAllocation("PAYOUT_REFUND", "LAST_INSTALLMENT"), //
createPaymentAllocation("MERCHANT_ISSUED_REFUND", "LAST_INSTALLMENT")));//
PostLoanProductsResponse responseLoanProductsRequestAdvPaymentAllocationActualActualProgressiveLoanSchedule = createLoanProductIdempotent(
loanProductsRequestAdvPaymentAllocationActualActualProgressiveLoanSchedule);
TestContext.INSTANCE.set(
TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADVANCED_PAYMENT_ALLOCATION_ACTUAL_ACTUAL_PROGRESSIVE_LOAN_SCHEDULE,
responseLoanProductsRequestAdvPaymentAllocationActualActualProgressiveLoanSchedule);
// LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_PRINCIPAL_FIRST
// Same as LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL but with PRINCIPAL before INTEREST
// in payment allocation order
@@ -88,6 +88,7 @@ public abstract class TestContextKey {
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_AUTO = "loanProductCreateResponseLP2DownPaymentAuto";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE = "loanProductCreateResponseLP2DownPaymentProgressiveLoanSchedule";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADVANCED_CUSTOM_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE = "loanProductCreateResponseLP2ProgressiveLoanScheduleCustomPaymentAllocation";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADVANCED_PAYMENT_ALLOCATION_ACTUAL_ACTUAL_PROGRESSIVE_LOAN_SCHEDULE = "loanProductCreateResponseLP2ProgressiveLoanScheduleActualActualAdvancedPaymentAllocation";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleVertical";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE_INSTALLMENT_LEVEL_DELINQUENCY = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleInstallmentLevelDelinquency";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleHorizontalInstallmentLevelDelinquencyCreditAllocation";
@@ -637,3 +637,61 @@ Feature: MerchantIssuedRefund
| 02 October 2025 | Interest Refund | 17.07 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | false |
| 02 October 2025 | Accrual | 18.33 | 0.0 | 18.33 | 0.0 | 0.0 | 0.0 | false | false |
| 02 October 2025 | Accrual Activity | 8.97 | 0.0 | 8.97 | 0.0 | 0.0 | 0.0 | false | false |
@TestRailId:C4355
Scenario: Verify manual Interest Refund is recalculated during reprocessing even if no prior transaction was reverse-replayed
When Admin sets the business date to "01 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_PMT_ALLOC_ACTUAL_ACTUAL_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 01 January 2025 | 100 | 26 | 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 "100" amount and expected disbursement date on "01 January 2025"
And Admin successfully disburse the loan on "01 January 2025" with "100" EUR transaction amount
And Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed |
| 01 January 2025 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false |
# MIR with interestRefundCalculation=false (no auto Interest Refund)
When Admin sets the business date to "01 February 2025"
And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "01 February 2025" with 66.41 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation false
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed |
| 01 January 2025 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false |
| 01 February 2025 | Merchant Issued Refund | 66.41 | 64.2 | 2.21 | 0.0 | 0.0 | 35.8 | false | false |
# Manually create Interest Refund with arbitrary amount (0.47 EUR)
When Admin manually adds Interest Refund for "MERCHANT_ISSUED_REFUND" transaction made on "01 February 2025" with 0.47 EUR interest refund amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed |
| 01 January 2025 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false |
| 01 February 2025 | Merchant Issued Refund | 66.41 | 64.2 | 2.21 | 0.0 | 0.0 | 35.8 | false | false |
| 01 February 2025 | Interest Refund | 0.47 | 0.47 | 0.0 | 0.0 | 0.0 | 35.33 | false | false |
# Backdated repayment on 20 January (before MIR) - triggers replay of MIR
And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "20 January 2025" with 17.94 EUR transaction amount and system-generated Idempotency key
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed |
| 01 January 2025 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false |
| 20 January 2025 | Repayment | 17.94 | 16.59 | 1.35 | 0.0 | 0.0 | 83.41 | false | false |
| 01 February 2025 | Merchant Issued Refund | 66.41 | 66.41 | 0.0 | 0.0 | 0.0 | 17.0 | false | true |
| 01 February 2025 | Interest Refund | 1.48 | 1.48 | 0.0 | 0.0 | 0.0 | 15.52 | false | true |
# Step 4: Make another repayment on 25 January (also before MIR)
And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "25 January 2025" with 10.94 EUR transaction amount and system-generated Idempotency key
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed |
| 01 January 2025 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false |
| 20 January 2025 | Repayment | 17.94 | 16.59 | 1.35 | 0.0 | 0.0 | 83.41 | false | false |
| 25 January 2025 | Repayment | 10.94 | 10.94 | 0.0 | 0.0 | 0.0 | 72.47 | false | false |
| 01 February 2025 | Merchant Issued Refund | 66.41 | 66.41 | 0.0 | 0.0 | 0.0 | 6.06 | false | true |
| 01 February 2025 | Interest Refund | 1.47 | 1.47 | 0.0 | 0.0 | 0.0 | 4.59 | false | true |
# Step 5: Reverse the 1st repayment (20 Jan) - this triggers full transaction reprocessing
# Key expectation: Interest Refund should be RECALCULATED even though MIR wasn't modified
# Before fix: Interest Refund kept 1.47 because no txn before it was changed
# After fix: Interest Refund recalculated to correct value
When Customer undo "1"th "Repayment" transaction made on "20 January 2025"
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed |
| 01 January 2025 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false |
| 20 January 2025 | Repayment | 17.94 | 16.59 | 1.35 | 0.0 | 0.0 | 83.41 | true | false |
| 25 January 2025 | Repayment | 10.94 | 10.94 | 0.0 | 0.0 | 0.0 | 89.06 | false | false |
| 01 February 2025 | Merchant Issued Refund | 66.41 | 64.26 | 2.15 | 0.0 | 0.0 | 24.8 | false | true |
| 01 February 2025 | Interest Refund | 1.46 | 1.46 | 0.0 | 0.0 | 0.0 | 23.34 | false | true |
#following steps will fail if Interest Refund is not recalculated properly
Then Loan has 23.97 outstanding amount
@@ -568,16 +568,22 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
.filter(LoanTransaction::isNotReversed).map(AbstractPersistableCustom::getId).toList();
final List<LoanTransaction> modifiedTransactions = new ArrayList<>(progCtx.getAlreadyProcessedTransactions().stream()
.filter(LoanTransaction::isNotReversed).filter(tr -> tr.getId() == null).toList());
final Money interestAfterRefund = interestRefundService.totalInterestByTransactions(this, loan.getId(), targetDate,
modifiedTransactions, unmodifiedTransactionIds);
final Money newAmount = interestBeforeRefund.minus(progCtx.getSumOfInterestRefundAmount()).minus(interestAfterRefund);
loanTransaction.updateAmount(newAmount.getAmount());
if (validateInterestRefundTransactionRelation(loanTransaction)) {
final Money interestAfterRefund = interestRefundService.totalInterestByTransactions(this, loan.getId(), targetDate,
modifiedTransactions, unmodifiedTransactionIds);
final Money newAmount = interestBeforeRefund.minus(progCtx.getSumOfInterestRefundAmount()).minus(interestAfterRefund);
loanTransaction.updateAmount(newAmount.getAmount());
}
progCtx.setSumOfInterestRefundAmount(progCtx.getSumOfInterestRefundAmount().add(loanTransaction.getAmount()));
}
}
handleRepayment(loanTransaction, ctx);
}
private boolean validateInterestRefundTransactionRelation(final LoanTransaction interestRefundTransaction) {
return interestRefundTransaction.getLoanTransactionRelations(tr -> tr.getToTransaction().getId() != null).isEmpty();
}
private boolean chargeOffIsInEffect(TransactionCtx ctx, LoanTransaction chargeOffTransaction, LoanTransaction loanTransaction) {
if (ctx instanceof ProgressiveTransactionCtx progressiveCtx && progressiveCtx.isChargedOff()) {
return true;
@@ -18,7 +18,6 @@
*/
package org.apache.fineract.integrationtests;
import static org.apache.fineract.portfolio.loanaccount.domain.Loan.DATE_FORMAT;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.REPLAYED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -47,7 +46,6 @@ import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest;
import org.apache.fineract.client.util.CallFailedRuntimeException;
import org.apache.fineract.integrationtests.common.BusinessStepHelper;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
@@ -64,7 +62,6 @@ public class LoanInterestRefundTest extends BaseLoanIntegrationTest {
private static RequestSpecification requestSpec;
private static LoanTransactionHelper loanTransactionHelper;
private static PostClientsResponse client;
private static BusinessStepHelper businessStepHelper;
@BeforeAll
public static void setup() {
@@ -1646,9 +1643,9 @@ public class LoanInterestRefundTest extends BaseLoanIntegrationTest {
disburseLoan(loanId, BigDecimal.valueOf(100.0), "1 January 2025");
final String transactionExternalId = UUID.randomUUID().toString();
final PostLoansLoanIdTransactionsResponse refundResponse = loanTransactionHelper
.makeMerchantIssuedRefund(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN)
.transactionDate("1 February 2025").locale("en").transactionAmount(66.41).externalId(transactionExternalId).interestRefundCalculation(false));
final PostLoansLoanIdTransactionsResponse refundResponse = loanTransactionHelper.makeMerchantIssuedRefund(loanId,
new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("1 February 2025").locale("en")
.transactionAmount(66.41).externalId(transactionExternalId).interestRefundCalculation(false));
Assertions.assertNotNull(refundResponse.getResourceId());
verifyTransactions(loanId, //
@@ -1657,16 +1654,16 @@ public class LoanInterestRefundTest extends BaseLoanIntegrationTest {
);
// Create manual interest refund via API
PostLoansLoanIdTransactionsResponse interestRefundResponse = loanTransactionHelper.createManualInterestRefund(loanId, refundResponse.getResourceId(),
"1 February 2025", 1.47, null);
loanTransactionHelper.createManualInterestRefund(loanId, refundResponse.getResourceId(), "1 February 2025", 0.47, null);
verifyTransactions(loanId, //
transaction(100.0, "Disbursement", "01 January 2025"), //
transaction(66.41, "Merchant Issued Refund", "01 February 2025"), //
transaction(1.47, "Interest Refund", "01 February 2025") //
transaction(0.47, "Interest Refund", "01 February 2025") //
);
PostLoansLoanIdTransactionsResponse repaymentResponse = loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "20 January 2025", 17.94);
PostLoansLoanIdTransactionsResponse repaymentResponse = loanTransactionHelper.makeLoanRepayment(loanId, "Repayment",
"20 January 2025", 17.94);
loanTransactionHelper.makeLoanRepayment(loanId, "Repayment", "25 January 2025", 10.94);
verifyTransactions(loanId, //
@@ -1678,12 +1675,20 @@ public class LoanInterestRefundTest extends BaseLoanIntegrationTest {
);
loanTransactionHelper.reverseLoanTransaction(loanId, repaymentResponse.getResourceId(),
new PostLoansLoanIdTransactionsTransactionIdRequest().dateFormat(DATETIME_PATTERN)
.transactionDate("25 January 2021").transactionAmount(0.0).locale("en"));
new PostLoansLoanIdTransactionsTransactionIdRequest().dateFormat(DATETIME_PATTERN).transactionDate("25 January 2025")
.transactionAmount(0.0).locale("en"));
verifyTransactions(loanId, //
transaction(100.0, "Disbursement", "01 January 2025"), //
reversedTransaction(17.94, "Repayment", "20 January 2025"), //
transaction(10.94, "Repayment", "25 January 2025"), //
transaction(66.41, "Merchant Issued Refund", "01 February 2025"), //
transaction(1.47, "Interest Refund", "01 February 2025") //
);
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
verifyLoanStatus(loanDetails, LoanStatus.OVERPAID);
Assertions.assertEquals(0.36, Utils.getDoubleValue(loanDetails.getTotalOverpaid()));
verifyLoanStatus(loanDetails, LoanStatus.ACTIVE);
Assertions.assertEquals(23.97, Utils.getDoubleValue(loanDetails.getSummary().getTotalOutstanding()));
});
}
@@ -36,7 +36,6 @@ import org.apache.fineract.client.models.GetLoansLoanIdTransactions;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
@@ -96,8 +95,8 @@ public class LoanManualInterestRefundResponseStructureTest extends BaseLoanInteg
targetTransactionIdRef.set(refundResponse.getResourceId());
// Create manual interest refund via API
PostLoansLoanIdTransactionsResponse interestRefundResponse = loanTransactionHelper.createManualInterestRefund(loanId, refundResponse.getResourceId(),
"15 January 2024", 5.0, null);
PostLoansLoanIdTransactionsResponse interestRefundResponse = loanTransactionHelper.createManualInterestRefund(loanId,
refundResponse.getResourceId(), "15 January 2024", 5.0, null);
assertNotNull(interestRefundResponse, "Interest refund response should not be null");
assertNotNull(interestRefundResponse.getResourceId(), "Interest refund resource ID should not be null");
@@ -157,8 +156,8 @@ public class LoanManualInterestRefundResponseStructureTest extends BaseLoanInteg
// Create manual interest refund with external ID
String interestRefundExternalId = UUID.randomUUID().toString();
PostLoansLoanIdTransactionsResponse interestRefundResponse = loanTransactionHelper.createManualInterestRefund(loanId, refundResponse.getResourceId(),
"15 February 2024", 5.0, interestRefundExternalId);
PostLoansLoanIdTransactionsResponse interestRefundResponse = loanTransactionHelper.createManualInterestRefund(loanId,
refundResponse.getResourceId(), "15 February 2024", 5.0, interestRefundExternalId);
assertNotNull(interestRefundResponse, "Interest refund response should not be null");
assertNotNull(interestRefundResponse.getResourceId(), "Interest refund resource ID should not be null");