diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java index 2f237396696..427e4fe74c3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java @@ -204,7 +204,14 @@ private boolean validateActivityTransaction(@NotNull LoanRepaymentScheduleInstal private void reverseAccrualActivityTransaction(LoanTransaction loanTransaction) { loanTransaction.reverse(); - loanTransaction.updateExternalId(null); + + boolean isReverseReplayed = loanTransaction.getLoanTransactionRelations().stream() + .anyMatch(relation -> LoanTransactionRelationTypeEnum.REPLAYED.equals(relation.getRelationType())); + + if (isReverseReplayed) { + loanTransaction.updateExternalId(null); + } + LoanAdjustTransactionBusinessEvent.Data data = new LoanAdjustTransactionBusinessEvent.Data(loanTransaction); businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(data)); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/RepaymentReverseExternalIdTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/RepaymentReverseExternalIdTest.java new file mode 100644 index 00000000000..df936e13846 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/RepaymentReverseExternalIdTest.java @@ -0,0 +1,148 @@ +/** + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.time.LocalDate; +import java.util.HashMap; +import java.util.UUID; +import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansLoanIdRequest; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class RepaymentReverseExternalIdTest extends BaseLoanIntegrationTest { + + private static final String loanAmount = "1000"; + private static final String startDate = "20 December 2024"; + private static final String firstRepaymentDate = "23 December 2024"; + private static final String secondRepaymentDate = "26 December 2024"; + private static final String reverseDate = "27 December 2024"; + private static final Double firstRepaymentAmount = 1000.0; + private static final Double secondRepaymentAmount = 10.0; + + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + private ClientHelper clientHelper; + private LoanTransactionHelper loanTransactionHelper; + + @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(this.requestSpec, this.responseSpec); + this.clientHelper = new ClientHelper(this.requestSpec, this.responseSpec); + } + + @Test + public void testReverseRepaymentUpdatesExternalIdCorrectlyForOverpayment() { + try { + // Set up the business date if required + final LocalDate todaysDate = Utils.getLocalDateOfTenant(); + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(true)); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, todaysDate); + + // Create a client and a loan with externalId + final Integer clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId().intValue(); + final GetLoanProductsProductIdResponse loanProduct = createLoanProduct(loanTransactionHelper, null); + final String loanExternalId = UUID.randomUUID().toString(); + final Integer loanId = createLoanAccount(clientId, loanProduct.getId(), loanExternalId); + + // First repayment to cover part of the loan + final PostLoansLoanIdTransactionsResponse repaymentTransaction1 = loanTransactionHelper.makeLoanRepayment(loanExternalId, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate(firstRepaymentDate).locale("en") + .transactionAmount(firstRepaymentAmount)); + + // Second repayment to create overpayment + final PostLoansLoanIdTransactionsResponse repaymentTransaction2 = loanTransactionHelper.makeLoanRepayment(loanExternalId, + new PostLoansLoanIdTransactionsRequest().dateFormat("dd MMMM yyyy").transactionDate(secondRepaymentDate).locale("en") + .transactionAmount(secondRepaymentAmount)); // This creates an overpayment as total is now + // 1010 + + // Verify that the loan is marked as overpaid + GetLoansLoanIdResponse loanDetailsOverpaid = loanTransactionHelper.getLoanDetails((long) loanId); + assertTrue(loanDetailsOverpaid.getStatus().getOverpaid()); // Overpaid status should be true + assertNotNull(loanDetailsOverpaid.getOverpaidOnDate()); // Overpaid date should be set + assertEquals(loanDetailsOverpaid.getExternalId(), loanExternalId); // externalId should match the original + + // Reverse the second repayment to remove the overpayment + loanTransactionHelper.reverseRepayment(loanId, repaymentTransaction2.getResourceId().intValue(), reverseDate); + + // Verify that the loan is no longer overpaid and overpaid date is reset + GetLoansLoanIdResponse loanDetailsNotOverpaid = loanTransactionHelper.getLoanDetails((long) loanId); + assertFalse(loanDetailsNotOverpaid.getStatus().getOverpaid()); // Overpaid status should be false + assertNull(loanDetailsNotOverpaid.getOverpaidOnDate()); // Overpaid date should be reset + assertEquals(loanDetailsNotOverpaid.getExternalId(), loanExternalId); // externalId should remain unchanged + + } finally { + // Disable business date configuration if it was enabled + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(false)); + } + } + + private GetLoanProductsProductIdResponse createLoanProduct(final LoanTransactionHelper loanTransactionHelper, + final Integer delinquencyBucketId) { + final HashMap loanProductMap = new LoanProductTestBuilder().build(null, delinquencyBucketId); + final Integer loanProductId = loanTransactionHelper.getLoanProductId(Utils.convertToJson(loanProductMap)); + return loanTransactionHelper.getLoanProduct(loanProductId); + } + + private Integer createLoanAccount(final Integer clientID, final Long loanProductID, final String externalId) { + + String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(loanAmount).withLoanTermFrequency("1") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("1").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("0").withInterestTypeAsFlatBalance() + .withAmortizationTypeAsEqualPrincipalPayments().withInterestCalculationPeriodTypeSameAsRepaymentPeriod() + .withExpectedDisbursementDate(startDate).withSubmittedOnDate(startDate).withLoanType("individual") + .withExternalId(externalId).build(clientID.toString(), loanProductID.toString(), null); + + final Integer loanId = loanTransactionHelper.getLoanId(loanApplicationJSON); + loanTransactionHelper.approveLoan(startDate, loanAmount, loanId, null); + loanTransactionHelper.disburseLoan(Long.valueOf(loanId), new PostLoansLoanIdRequest().actualDisbursementDate(startDate) + .transactionAmount(new BigDecimal(loanAmount)).locale("en").dateFormat("dd MMMM yyyy")); + return loanId; + } +}