From 2f5ec165502582b567ffaf05a4beda75e2a95cc1 Mon Sep 17 00:00:00 2001 From: Ashhar Ahmad Khan <145142826+AshharAhmadKhan@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:56:20 +0530 Subject: [PATCH] FINERACT-2615: Support undoing of Account transfer from Loan account to Savings account Implements undo for Loan-to-Savings account transfers, which previously threw UnsupportedOperationException. Reverses the savings deposit via SavingsAccountWritePlatformService.undoTransaction and reverses the loan refund transaction via LoanAccountDomainService.reverseTransfer, then marks the AccountTransferTransaction as reversed so repeated undo attempts are correctly rejected. Adds unit test coverage for the new undo branch and an e2e scenario (TestRailId C80938) mirroring the existing Savings-to-Loan undo test. --- .../account/AccountTransferStepDef.java | 11 ++ .../features/AccountTransfer.feature | 42 ++++++ ...ountTransfersWritePlatformServiceImpl.java | 7 +- .../domain/LoanAccountDomainServiceJpa.java | 1 + ...TransfersWritePlatformServiceImplTest.java | 122 ++++++++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImplTest.java diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/account/AccountTransferStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/account/AccountTransferStepDef.java index 7bb5a8d8c4d..8b9d0110b04 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/account/AccountTransferStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/account/AccountTransferStepDef.java @@ -56,6 +56,17 @@ public void initiateAccountTransfer(String date, double amount) { createAccountTransfer(clientId, savingsId, SAVINGS_ACCOUNT_TYPE, loanId, LOAN_ACCOUNT_TYPE, date, amount); } + @When("Initiate account transfer from loan to savings on {string} for {double}") + public void initiateLoanToSavingsTransfer(String date, double amount) { + PostClientsResponse clientResponse = testContext().get(TestContextKey.CLIENT_CREATE_RESPONSE); + long clientId = clientResponse.getClientId(); + long loanId = ((PostLoansResponse) testContext().get(TestContextKey.LOAN_CREATE_RESPONSE)).getLoanId(); + long savingsId = ((PostSavingsAccountsResponse) testContext().get(TestContextKey.EUR_SAVINGS_ACCOUNT_CREATE_RESPONSE)) + .getSavingsId(); + + createAccountTransfer(clientId, loanId, LOAN_ACCOUNT_TYPE, savingsId, SAVINGS_ACCOUNT_TYPE, date, amount); + } + @When("Initiate account transfer from savings {string} to savings {string} on {string} for {double}") public void initiateAccountTransferBetweenSavings(String fromAlias, String toAlias, String date, double amount) { PostClientsResponse clientResponse = testContext().get(TestContextKey.CLIENT_CREATE_RESPONSE); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/AccountTransfer.feature b/fineract-e2e-tests-runner/src/test/resources/features/AccountTransfer.feature index f290c7879c2..1042ba5d5a6 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/AccountTransfer.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/AccountTransfer.feature @@ -38,6 +38,48 @@ Feature: AccountTransfer | 02 May 2026 | Repayment | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 990.0 | true | false | When Undo the last account transfer it fails with error: it is already reverted + @TestRailId:C80938 + Scenario: Transfer from loan to savings then undo it + When Admin sets the business date to "13 May 2026" + And Admin creates a client with random data + And Admin creates a EUR savings product + And Client creates a new EUR savings account with "01 May 2026" submitted on date + And Approve EUR savings account on "01 May 2026" date + And Activate EUR savings account on "01 May 2026" date + And Client successfully deposits 1000 EUR to the savings account on "01 May 2026" date + Then Savings Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Balance | + | 01 May 2026 | Deposit | 1000.0 | 1000.0 | + When 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_PYMNT_INTEREST_DAILY_EMI_360_30 | 01 May 2026 | 1000 | 12 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 May 2026" with "1000" amount and expected disbursement date on "01 May 2026" + When Admin successfully disburse the loan on "01 May 2026" with "1000" EUR transaction amount + And Customer makes "AUTOPAY" repayment on "02 May 2026" with 1100 EUR transaction amount + When Initiate account transfer from loan to savings on "2 May 2026" for 10 + Then Savings Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Balance | + | 01 May 2026 | Deposit | 1000.0 | 1000.0 | + | 02 May 2026 | Deposit | 10.0 | 1010.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 May 2026 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 02 May 2026 | Repayment | 1100.0 | 1000.0 | 35.28 | 0.0 | 0.0 | 0.0 | false | false | + | 02 May 2026 | Transfer Refund | 10.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | false | + | 13 May 2026 | Accrual | 35.28 | 0.0 | 35.28 | 0.0 | 0.0 | 0.0 | false | false | + When Undo the last account transfer + Then Savings Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Balance | Reverted | + | 01 May 2026 | Deposit | 1000.0 | 1000.0 | false | + | 02 May 2026 | Deposit | 10.0 | 0.0 | true | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 May 2026 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 02 May 2026 | Repayment | 1100.0 | 1000.0 | 35.28 | 0.0 | 0.0 | 0.0 | false | false | + | 02 May 2026 | Transfer Refund | 10.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | true | false | + | 13 May 2026 | Accrual | 35.28 | 0.0 | 35.28 | 0.0 | 0.0 | 0.0 | false | false | + When Undo the last account transfer it fails with error: it is already reverted + @TestRailId:C80967 Scenario: Transfer between savings accounts When Admin sets the business date to "13 May 2026" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 8fce2c4ecec..20f20d06150 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -538,7 +538,12 @@ public CommandProcessingResult undo(JsonCommand command) { transaction.reverse(); }); } else if (isLoanToSavingsAccountTransfer(fromAccountType, toAccountType)) { - throw new UnsupportedOperationException("Undo Loan to Savings Account Transfer is not implemented"); + accountTransferDetails.getAccountTransferTransactions().forEach(transaction -> { + this.savingsAccountWritePlatformService.undoTransaction(transaction.getToSavingsTransaction().getSavingsAccount().getId(), + transaction.getToSavingsTransaction().getId(), true); + this.loanAccountDomainService.reverseTransfer(transaction.getFromLoanTransaction()); + transaction.reverse(); + }); } final CommandProcessingResultBuilder builder = new CommandProcessingResultBuilder() // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java index 0b7814337c4..042a5b7054b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java @@ -569,6 +569,7 @@ public void reverseTransfer(final LoanTransaction loanTransaction) { } loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(loanTransaction.getLoan(), loanTransaction, "reversed"); loanTransaction.reverse(); + loanTransaction.manuallyAdjustedOrReversed(); loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(loanTransaction); } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImplTest.java new file mode 100644 index 00000000000..f3322ad9c23 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImplTest.java @@ -0,0 +1,122 @@ +/** + * 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.portfolio.account.service; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; +import org.apache.fineract.portfolio.account.domain.AccountTransferDetails; +import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AccountTransfersWritePlatformServiceImplTest { + + @InjectMocks + private AccountTransfersWritePlatformServiceImpl underTest; + + @Mock + private AccountTransferDetailRepository accountTransferDetailRepository; + @Mock + private SavingsAccountWritePlatformService savingsAccountWritePlatformService; + @Mock + private LoanAccountDomainService loanAccountDomainService; + @Mock + private ExternalIdFactory externalIdFactory; + @Mock + private JsonCommand command; + + @Test + void givenLoanToSavingsTransfer_whenUndo_thenSavingsUndoneLoanReversedAndTransferMarkedReversed() { + // Arrange + AccountTransferDetails accountTransferDetails = mock(AccountTransferDetails.class); + AccountTransferTransaction transaction = mock(AccountTransferTransaction.class); + Loan fromLoanAccount = mock(Loan.class); + SavingsAccount toSavingsAccount = mock(SavingsAccount.class); + LoanTransaction fromLoanTransaction = mock(LoanTransaction.class); + SavingsAccountTransaction toSavingsTransaction = mock(SavingsAccountTransaction.class); + + when(command.entityId()).thenReturn(1L); + when(accountTransferDetailRepository.findById(1L)).thenReturn(java.util.Optional.of(accountTransferDetails)); + when(accountTransferDetails.getId()).thenReturn(1L); + when(accountTransferDetails.getAccountTransferTransactions()).thenReturn(List.of(transaction)); + when(transaction.isReversed()).thenReturn(false); + + when(accountTransferDetails.fromLoanAccount()).thenReturn(fromLoanAccount); + when(accountTransferDetails.toLoanAccount()).thenReturn(null); + when(accountTransferDetails.toSavingsAccount()).thenReturn(toSavingsAccount); + + when(transaction.getToSavingsTransaction()).thenReturn(toSavingsTransaction); + when(toSavingsTransaction.getSavingsAccount()).thenReturn(toSavingsAccount); + when(toSavingsAccount.getId()).thenReturn(2L); + when(toSavingsTransaction.getId()).thenReturn(3L); + when(transaction.getFromLoanTransaction()).thenReturn(fromLoanTransaction); + + // Act + underTest.undo(command); + + // Assert + verify(savingsAccountWritePlatformService).undoTransaction(2L, 3L, true); + verify(loanAccountDomainService).reverseTransfer(fromLoanTransaction); + verify(transaction).reverse(); + } + + @Test + void givenAlreadyReversedTransfer_whenUndo_thenThrowsGeneralPlatformDomainRuleException() { + // Arrange + AccountTransferDetails accountTransferDetails = mock(AccountTransferDetails.class); + AccountTransferTransaction transaction = mock(AccountTransferTransaction.class); + + when(command.entityId()).thenReturn(1L); + when(accountTransferDetailRepository.findById(1L)).thenReturn(java.util.Optional.of(accountTransferDetails)); + when(accountTransferDetails.getAccountTransferTransactions()).thenReturn(List.of(transaction)); + when(transaction.isReversed()).thenReturn(true); + + // Act & Assert + try { + underTest.undo(command); + org.junit.jupiter.api.Assertions.fail("Expected GeneralPlatformDomainRuleException"); + } catch (GeneralPlatformDomainRuleException e) { + // expected + } + + verify(savingsAccountWritePlatformService, never()).undoTransaction(anyLong(), anyLong(), + org.mockito.ArgumentMatchers.anyBoolean()); + verify(loanAccountDomainService, never()).reverseTransfer(org.mockito.ArgumentMatchers.any()); + verify(transaction, never()).reverse(); + } +}