From 8fe9dbc614bd86c408627a0d964211c8a77df820 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Tue, 19 Aug 2025 07:23:17 +0200 Subject: [PATCH] FINERACT-2327: when Interest Refund was created manually, we should only return the newly created Interest Refund transaction identifiers in the result. --- .../messaging/event/EventCheckHelper.java | 3 +- ...WritePlatformServiceJpaRepositoryImpl.java | 6 +- ...alInterestRefundResponseStructureTest.java | 246 ++++++++++++++++++ .../common/loans/LoanTransactionHelper.java | 6 + 4 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanManualInterestRefundResponseStructureTest.java diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java index d3f876f00..9b1c390d3 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java @@ -309,8 +309,7 @@ public class EventCheckHelper { Response transactionResponse, TransactionType transactionType, String externalOwnerId) throws IOException { Long loanId = transactionResponse.body().getLoanId(); - Long transactionId = transactionType.equals(TransactionType.INTEREST_REFUND) ? transactionResponse.body().getSubResourceId() - : transactionResponse.body().getResourceId(); + Long transactionId = transactionResponse.body().getResourceId(); Response loanDetailsResponse = loansApi.retrieveLoan(loanId, false, "transactions", "", "").execute(); List transactions = loanDetailsResponse.body().getTransactions(); GetLoansLoanIdTransactions transactionFound = transactions// diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 6594caad5..a9d4348ed 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -3034,10 +3034,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withLoanId(loan.getId()) // - .withEntityId(targetTransaction.getId()) // - .withEntityExternalId(targetTransaction.getExternalId()) // - .withSubEntityId(interestRefundTxn.getId()) // - .withSubEntityExternalId(interestRefundTxn.getExternalId()) // + .withEntityId(interestRefundTxn.getId()) // + .withEntityExternalId(interestRefundTxn.getExternalId()) // .withOfficeId(loan.getOfficeId()) // .withClientId(loan.getClientId()) // .withGroupId(loan.getGroupId()) // diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanManualInterestRefundResponseStructureTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanManualInterestRefundResponseStructureTest.java new file mode 100644 index 000000000..fe6923349 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanManualInterestRefundResponseStructureTest.java @@ -0,0 +1,246 @@ +/** + * 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.assertNull; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.math.BigDecimal; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +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.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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests validate that manual Interest Refund transactions return the correct response structure: - entityId should + * contain the Interest Refund transaction ID - entityExternalId should contain the Interest Refund external ID - + * subEntityId should be null/not set - subEntityExternalId should be null/not set + */ +@Slf4j +public class LoanManualInterestRefundResponseStructureTest extends BaseLoanIntegrationTest { + + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + private LoanTransactionHelper loanTransactionHelper; + private PostClientsResponse client; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.loanTransactionHelper = new LoanTransactionHelper(requestSpec, responseSpec); + this.client = ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + } + + @Test + public void testManualInterestRefundResponseStructureWithoutExternalIds() { + AtomicReference loanIdRef = new AtomicReference<>(); + AtomicReference targetTransactionIdRef = new AtomicReference<>(); + + runAt("01 January 2024", () -> { + // Create loan product that supports manual interest refund + PostLoanProductsResponse loanProduct = loanProductHelper + .createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.ACTUAL) + .addSupportedInterestRefundTypesItem(SupportedInterestRefundTypesItem.MERCHANT_ISSUED_REFUND) + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)); + + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProduct.getResourceId(), "01 January 2024", 1000.0, 9.9, + 12, null); + assertNotNull(loanId); + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(1000), "01 January 2024"); + }); + + runAt("15 January 2024", () -> { + Long loanId = loanIdRef.get(); + + // Make a merchant issued refund to have a target transaction that supports manual interest refund + PostLoansLoanIdTransactionsResponse refundResponse = makeLoanMerchantIssuedRefund(loanId, "15 January 2024", 100.0); + assertNotNull(refundResponse); + assertNotNull(refundResponse.getResourceId()); + targetTransactionIdRef.set(refundResponse.getResourceId()); + + // Create manual interest refund via API + PostLoansLoanIdTransactionsResponse interestRefundResponse = 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"); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + GetLoansLoanIdTransactions interestRefundTransaction = findTransactionByType(loanDetails, "Interest Refund"); + assertNotNull(interestRefundTransaction, "Interest Refund transaction should exist"); + + assertEquals(interestRefundTransaction.getId(), interestRefundResponse.getResourceId(), + "Response entityId should be the Interest Refund transaction ID"); + + // entityExternalId should be null (since no external ID was provided) + assertNull(interestRefundResponse.getResourceExternalId(), "entityExternalId should be null when no external ID provided"); + + // subEntityId should be null (not the target transaction ID) + assertNull(interestRefundResponse.getSubResourceId(), "subEntityId should be null"); + + // subEntityExternalId should be null + assertNull(interestRefundResponse.getSubResourceExternalId(), "subEntityExternalId should be null"); + }); + } + + @Test + public void testManualInterestRefundResponseStructureWithExternalIds() { + AtomicReference loanExternalIdRef = new AtomicReference<>(); + AtomicReference loanIdRef = new AtomicReference<>(); + AtomicReference targetTransactionExternalIdRef = new AtomicReference<>(); + + String loanExternalId = UUID.randomUUID().toString(); + loanExternalIdRef.set(loanExternalId); + + runAt("01 February 2024", () -> { + // Create loan product that supports manual interest refund + PostLoanProductsResponse loanProduct = loanProductHelper + .createLoanProduct(create4IProgressive().daysInMonthType(DaysInMonthType.ACTUAL).daysInYearType(DaysInYearType.ACTUAL) + .addSupportedInterestRefundTypesItem(SupportedInterestRefundTypesItem.MERCHANT_ISSUED_REFUND) + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)); + + Long loanId = applyAndApproveProgressiveLoanWithExternalId(client.getClientId(), loanProduct.getResourceId(), loanExternalId, + "01 February 2024", 1000.0, 9.9, 12, null); + assertNotNull(loanId); + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(1000), "01 February 2024"); + }); + + runAt("15 February 2024", () -> { + Long loanId = loanIdRef.get(); + String repaymentExternalId = UUID.randomUUID().toString(); + targetTransactionExternalIdRef.set(repaymentExternalId); + + // Make a merchant issued refund with external ID (without automatic interest refund) + PostLoansLoanIdTransactionsResponse refundResponse = makeLoanMerchantIssuedRefundWithExternalId(loanId, repaymentExternalId, + "15 February 2024", 100.0); + assertNotNull(refundResponse); + assertNotNull(refundResponse.getResourceId()); + + // Create manual interest refund with external ID + String interestRefundExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse interestRefundResponse = 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"); + + // Get the actual interest refund transaction to verify + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + GetLoansLoanIdTransactions interestRefundTransaction = findTransactionByType(loanDetails, "Interest Refund"); + assertNotNull(interestRefundTransaction, "Interest Refund transaction should exist"); + + assertEquals(interestRefundTransaction.getId(), interestRefundResponse.getResourceId(), + "Response entityId should be the Interest Refund transaction ID"); + + assertEquals(interestRefundExternalId, interestRefundResponse.getResourceExternalId(), + "entityExternalId should be the Interest Refund external ID"); + + assertNull(interestRefundResponse.getSubResourceId(), "subEntityId should be null"); + + assertNull(interestRefundResponse.getSubResourceExternalId(), "subEntityExternalId should be null"); + }); + } + + /** + * Helper method to create manual interest refund transaction + */ + private PostLoansLoanIdTransactionsResponse createManualInterestRefund(Long loanId, Long targetTransactionId, String transactionDate, + Double amount, String externalId) { + + PostLoansLoanIdTransactionsTransactionIdRequest request = new PostLoansLoanIdTransactionsTransactionIdRequest() + .transactionAmount(amount).dateFormat("dd MMMM yyyy").locale("en"); + + if (externalId != null) { + request.externalId(externalId); + } + + return loanTransactionHelper.manualInterestRefund(loanId, targetTransactionId, request); + } + + /** + * Helper method to make loan merchant issued refund (without automatic interest refund) + */ + private PostLoansLoanIdTransactionsResponse makeLoanMerchantIssuedRefund(Long loanId, String transactionDate, Double amount) { + // Create merchant issued refund transaction without automatic interest refund + org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest request = new org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest() + .transactionDate(transactionDate).transactionAmount(amount).interestRefundCalculation(false).dateFormat("dd MMMM yyyy") + .locale("en"); + return loanTransactionHelper.makeMerchantIssuedRefund(loanId, request); + } + + /** + * Helper method to make loan merchant issued refund with external ID (without automatic interest refund) + */ + private PostLoansLoanIdTransactionsResponse makeLoanMerchantIssuedRefundWithExternalId(Long loanId, String externalId, + String transactionDate, Double amount) { + // Create merchant issued refund transaction with external ID but without automatic interest refund + org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest request = new org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest() + .transactionDate(transactionDate).transactionAmount(amount).externalId(externalId).interestRefundCalculation(false) + .dateFormat("dd MMMM yyyy").locale("en"); + return loanTransactionHelper.makeMerchantIssuedRefund(loanId, request); + } + + /** + * Helper method to find transaction by type + */ + private GetLoansLoanIdTransactions findTransactionByType(GetLoansLoanIdResponse loanDetails, String transactionType) { + return loanDetails.getTransactions().stream().filter(t -> transactionType.equals(t.getType().getValue())).findFirst().orElse(null); + } + + /** + * Helper method to apply and approve progressive loan with external ID + */ + private Long applyAndApproveProgressiveLoanWithExternalId(Long clientId, Long productId, String loanExternalId, String submittedDate, + Double amount, Double interestRate, Integer termFrequency, + java.util.function.Consumer customizer) { + + org.apache.fineract.client.models.PostLoansRequest request = applyLP2ProgressiveLoanRequest(clientId, productId, submittedDate, + amount, interestRate, termFrequency, customizer); + request.externalId(loanExternalId); + + org.apache.fineract.client.models.PostLoansResponse loanResponse = loanTransactionHelper.applyLoan(request); + Long loanId = loanResponse.getLoanId(); + + loanTransactionHelper.approveLoan(loanId, approveLoanRequest(amount, submittedDate)); + return loanId; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index 889308229..39a75fcce 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -1181,6 +1181,12 @@ public class LoanTransactionHelper { FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction1(loanExternalId, request, "chargeRefund")); } + public PostLoansLoanIdTransactionsResponse manualInterestRefund(final Long loanId, final Long targetTransactionId, + final PostLoansLoanIdTransactionsTransactionIdRequest request) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction(loanId, targetTransactionId, + request, "interest-refund")); + } + public PostLoansLoanIdTransactionsResponse makeGoodwillCredit(final Long loanId, final PostLoansLoanIdTransactionsRequest request) { return Calls .ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, request, "goodwillCredit"));