From 9a69cc4826f38ea96a86d0088fc89e8aa63ffd5b Mon Sep 17 00:00:00 2001 From: Ralph Hopman Date: Wed, 11 Feb 2026 09:05:49 +0100 Subject: [PATCH] FINERACT-2482: On-hold transactions API returns empty results for group savings accounts --- ...oldTransactionReadPlatformServiceImpl.java | 8 +- .../GroupSavingsIntegrationTest.java | 145 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountOnHoldTransactionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountOnHoldTransactionReadPlatformServiceImpl.java index b19f00d99..57090513b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountOnHoldTransactionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountOnHoldTransactionReadPlatformServiceImpl.java @@ -94,16 +94,18 @@ public class DepositAccountOnHoldTransactionReadPlatformServiceImpl implements D sqlBuilder.append(" tr.id as transactionId, tr.transaction_type_enum as transactionType, "); sqlBuilder.append(" tr.transaction_date as transactionDate, tr.amount as transactionAmount,"); sqlBuilder.append(" tr.is_reversed as reversed, sa.account_no as savingsAccNum, "); - sqlBuilder.append("sc.display_name as savingsClientName, ml.id as loanid, sa.id as savingid, "); - sqlBuilder.append(" ml.account_no as loanAccountNum, lc.display_name as loanClientName"); + sqlBuilder.append(" COALESCE(sc.display_name, sg.display_name) as savingsClientName, ml.id as loanid, sa.id as savingid, "); + sqlBuilder.append(" ml.account_no as loanAccountNum, COALESCE(lc.display_name, lg.display_name) as loanClientName"); sqlBuilder.append(" from m_savings_account sa "); sqlBuilder.append(" join m_deposit_account_on_hold_transaction tr on sa.id = tr.savings_account_id "); - sqlBuilder.append(" join m_client sc on sc.id = sa.client_id"); + sqlBuilder.append(" left join m_client sc on sc.id = sa.client_id"); + sqlBuilder.append(" left join m_group sg on sg.id = sa.group_id"); sqlBuilder.append(" left join m_guarantor_transaction gt on gt.deposit_on_hold_transaction_id = tr.id "); sqlBuilder.append(" left join m_guarantor_funding_details mgfd on mgfd.id=gt.guarantor_fund_detail_id"); sqlBuilder.append(" left join m_portfolio_account_associations pa on pa.id=mgfd.account_associations_id"); sqlBuilder.append(" left join m_loan ml on ml.id = pa.loan_account_id"); sqlBuilder.append(" left join m_client lc on lc.id = ml.client_id"); + sqlBuilder.append(" left join m_group lg on lg.id = ml.group_id"); this.schemaSql = sqlBuilder.toString(); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java index b2354792d..69043adff 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/GroupSavingsIntegrationTest.java @@ -43,10 +43,15 @@ import org.apache.fineract.integrationtests.common.GroupHelper; import org.apache.fineract.integrationtests.common.PaymentTypeHelper; import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +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.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; import org.apache.fineract.integrationtests.common.savings.SavingsStatusChecker; import org.apache.fineract.integrationtests.common.savings.SavingsTestLifecycleExtension; +import org.apache.fineract.integrationtests.guarantor.GuarantorHelper; +import org.apache.fineract.integrationtests.guarantor.GuarantorTestBuilder; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,12 +70,16 @@ public class GroupSavingsIntegrationTest { public static final String WITHDRAW_AMOUNT = "1000"; public static final String WITHDRAW_AMOUNT_ADJUSTED = "500"; public static final String MINIMUM_OPENING_BALANCE = "1000.0"; + public static final String PRINCIPAL = "5000"; + public static final String GUARANTEE_AMOUNT = "500"; public static final String ACCOUNT_TYPE_GROUP = "GROUP"; private ResponseSpecification responseSpec; private RequestSpecification requestSpec; private SavingsAccountHelper savingsAccountHelper; private PaymentTypeHelper paymentTypeHelper; + private LoanTransactionHelper loanTransactionHelper; + private GuarantorHelper guarantorHelper; private static final Logger LOG = LoggerFactory.getLogger(GroupSavingsIntegrationTest.class); @BeforeEach @@ -81,6 +90,10 @@ public class GroupSavingsIntegrationTest { this.requestSpec.header("Fineract-Platform-TenantId", "default"); this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); this.paymentTypeHelper = new PaymentTypeHelper(); + // Use a default responseSpec for loan operations that doesn't enforce status code + ResponseSpecification loanResponseSpec = new ResponseSpecBuilder().build(); + this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, loanResponseSpec); + this.guarantorHelper = new GuarantorHelper(this.requestSpec, loanResponseSpec); } @Test @@ -876,4 +889,136 @@ public class GroupSavingsIntegrationTest { return map; } + /** + * Test that verifies the /savingsaccounts/{savingsId}/onholdtransactions API endpoint works correctly for GROUP + * savings accounts when used as guarantor collateral: + * + */ + @Test + public void testOnHoldTransactionsApiForGroupSavingsAccount() { + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + + // Create a client who will take out the loan + final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); + ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); + Assertions.assertNotNull(clientID); + + // Create a group with a savings account that will act as collateral + final Integer groupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true); + Assertions.assertNotNull(groupID); + + // Create a savings product + final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE, null, null, + "false"); + Assertions.assertNotNull(savingsProductID); + + // Create and activate a group savings account + final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(groupID, savingsProductID, ACCOUNT_TYPE_GROUP); + Assertions.assertNotNull(savingsId); + + HashMap savingsStatusHashMap = this.savingsAccountHelper.approveSavings(savingsId); + SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap); + + savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId); + SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); + + // After activation, the account has MINIMUM_OPENING_BALANCE (1000) that can be used as collateral + // No need for additional deposit + + // Create a loan product with hold funds enabled + // Note: Using 0,0,0 to bypass bug FINERACT-2476 where group accounts can't be guarantors + // with non-zero guarantee requirements. This test focuses on the SQL query fix. + LoanProductTestBuilder loanProductBuilder = new LoanProductTestBuilder().withPrincipal(PRINCIPAL).withNumberOfRepayments("4") + .withRepaymentAfterEvery("1").withRepaymentTypeAsWeek().withinterestRatePerPeriod("2") + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsDecliningBalance() + .withOnHoldFundDetails("0", "0", "0"); + final String loanProductJSON = loanProductBuilder.build(null); + final Integer loanProductID = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + Assertions.assertNotNull(loanProductID); + + // Apply for a loan for the client + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal(PRINCIPAL).withLoanTermFrequency("4") + .withLoanTermFrequencyAsWeeks().withNumberOfRepayments("4").withRepaymentEveryAfter("1").withRepaymentFrequencyTypeAsWeeks() + .withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod().withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE) + .withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE) + .build(clientID.toString(), loanProductID.toString(), null); + final Integer loanID = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + Assertions.assertNotNull(loanID); + LOG.info("Created loan with ID: {}", loanID); + + // Add the group savings account as guarantor collateral for the loan + // Use GUARANTEE_AMOUNT (500) as guarantee amount (less than the MINIMUM_OPENING_BALANCE of 1000) + String guarantorJSON = new GuarantorTestBuilder() + .existingCustomerWithGuaranteeAmount(String.valueOf(groupID), String.valueOf(savingsId), GUARANTEE_AMOUNT).build(); + + LOG.info("Guarantor JSON: {}", guarantorJSON); + LOG.info("Loan ID: {}, Group ID: {}, Savings ID: {}", loanID, groupID, savingsId); + + Integer guarantorId = this.guarantorHelper.createGuarantor(loanID, guarantorJSON); + LOG.info("Guarantor ID: {}", guarantorId); + Assertions.assertNotNull(guarantorId, "Guarantor creation should return a valid ID"); + + // Approve the loan - this will create the hold transaction on the group savings account + HashMap loanStatusHashMap = this.loanTransactionHelper.approveLoan(SavingsAccountHelper.TRANSACTION_DATE, loanID); + Assertions.assertNotNull(loanStatusHashMap); + + // Call the on-hold transactions API endpoint + final String ON_HOLD_TRANSACTIONS_URL = "/fineract-provider/api/v1/savingsaccounts/" + savingsId + "/onholdtransactions?" + + Utils.TENANT_IDENTIFIER; + HashMap onHoldTransactionsResponse = Utils.performServerGet(this.requestSpec, this.responseSpec, ON_HOLD_TRANSACTIONS_URL, ""); + Assertions.assertNotNull(onHoldTransactionsResponse); + + // Verify the response structure + Assertions.assertTrue(onHoldTransactionsResponse.containsKey("totalFilteredRecords"), + "Response should contain totalFilteredRecords"); + Assertions.assertTrue(onHoldTransactionsResponse.containsKey("pageItems"), "Response should contain pageItems"); + + // Verify we have at least one transaction (the guarantor hold we just created) + Integer totalRecords = (Integer) onHoldTransactionsResponse.get("totalFilteredRecords"); + Assertions.assertTrue(totalRecords > 0, "Should have at least one on-hold transaction"); + + // Get the page items + ArrayList pageItems = (ArrayList) onHoldTransactionsResponse.get("pageItems"); + Assertions.assertNotNull(pageItems, "pageItems should not be null"); + Assertions.assertFalse(pageItems.isEmpty(), "pageItems should not be empty"); + + LOG.info("Found {} on-hold transactions", pageItems.size()); + + // Verify that at least one transaction has the group name populated + boolean foundTransactionWithGroupName = false; + for (HashMap transaction : pageItems) { + LOG.info("Transaction: {}", transaction); + String savingsClientName = (String) transaction.get("savingsClientName"); + if (savingsClientName != null && !savingsClientName.isBlank()) { + foundTransactionWithGroupName = true; + + // Verify transaction details + Assertions.assertNotNull(transaction.get("amount"), "Transaction amount should not be null"); + + // Verify savings account details are present + String savingsAccNum = (String) transaction.get("savingsAccountNo"); + Assertions.assertNotNull(savingsAccNum, "savingsAccountNo should not be null"); + + // Verify savings ID matches + Integer savingsIdFromTransaction = (Integer) transaction.get("savingsId"); + Assertions.assertEquals(savingsId, savingsIdFromTransaction, "savingsId should match"); + + // Verify transaction date is present + Assertions.assertNotNull(transaction.get("transactionDate"), "transactionDate should not be null"); + + LOG.info("SUCCESS: Found on-hold transaction with group name '{}' for group savings account", savingsClientName); + break; + } + } + + Assertions.assertTrue(foundTransactionWithGroupName, + "Should find at least one on-hold transaction with savingsClientName populated (group name)"); + } + }