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(); + } +}