FINERACT-2473: Add available balance to group accounts endpoint (#5468)

This commit is contained in:
Ralph Hopman
2026-02-14 16:15:34 -06:00
committed by GitHub
parent 0ea65d820d
commit 05662e64d5
4 changed files with 311 additions and 10 deletions
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.accountdetails.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountApplicationTimelineData;
@@ -29,6 +30,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumDat
/**
* Immutable data object for savings accounts.
*/
@Getter
@SuppressWarnings("unused")
public class SavingsAccountSummaryData {
@@ -40,7 +42,27 @@ public class SavingsAccountSummaryData {
private final String shortProductName;
private final SavingsAccountStatusEnumData status;
private final CurrencyData currency;
/**
* The total balance of the savings account.
*/
private final BigDecimal accountBalance;
/**
* Funds held as collateral for loan guarantees. When a savings account is used as a guarantor for a loan, the
* guarantee amount is held here and unavailable for withdrawal until the loan is repaid or the guarantee is
* released. Sourced from the database column on_hold_funds_derived.
*/
private final BigDecimal onHoldFunds;
/**
* User-initiated holds explicitly placed on the account, including lien holds. These are manual holds placed
* through hold/release transactions to restrict withdrawals for specific purposes. Sourced from the database column
* total_savings_amount_on_hold.
*/
private final BigDecimal savingsAmountOnHold;
/**
* The actual available balance that can be withdrawn, accounting for all holds. Calculated as: accountBalance -
* onHoldFunds (guarantor holds) - savingsAmountOnHold (user/lien holds)
*/
private final BigDecimal availableBalance;
// differentiate Individual, JLG or Group account
private final EnumOptionData accountType;
private final SavingsAccountApplicationTimelineData timeline;
@@ -52,7 +74,8 @@ public class SavingsAccountSummaryData {
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
this.id = id;
this.accountNo = accountNo;
@@ -63,18 +86,13 @@ public class SavingsAccountSummaryData {
this.status = status;
this.currency = currency;
this.accountBalance = accountBalance;
this.onHoldFunds = onHoldFunds;
this.savingsAmountOnHold = savingsAmountOnHold;
this.availableBalance = availableBalance;
this.accountType = accountType;
this.timeline = timeline;
this.depositType = depositType;
this.subStatus = subStatus;
this.lastActiveTransactionDate = lastActiveTransactionDate;
}
public String getAccountNo() {
return accountNo;
}
public Long getId() {
return id;
}
}
@@ -311,6 +311,8 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, sa.status_enum as statusEnum, ");
accountsSummary.append("sa.account_type_enum as accountType, ");
accountsSummary.append("sa.account_balance_derived as accountBalance, ");
accountsSummary.append("sa.on_hold_funds_derived as onHoldFunds, ");
accountsSummary.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
accountsSummary.append("sa.submittedon_date as submittedOnDate,");
accountsSummary.append("sbu.username as submittedByUsername,");
@@ -377,6 +379,18 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final String shortProductName = rs.getString("shortProductName");
final Integer statusId = JdbcSupport.getInteger(rs, "statusEnum");
final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "accountBalance");
final BigDecimal onHoldFunds = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldFunds");
final BigDecimal onHoldAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldAmount");
BigDecimal availableBalance = accountBalance;
if (availableBalance != null && onHoldFunds != null) {
availableBalance = availableBalance.subtract(onHoldFunds);
}
if (availableBalance != null && onHoldAmount != null) {
availableBalance = availableBalance.subtract(onHoldAmount);
}
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusId);
final Integer accountType = JdbcSupport.getInteger(rs, "accountType");
final EnumOptionData accountTypeData = AccountEnumerations.loanType(accountType);
@@ -433,7 +447,8 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, accountTypeData, timeline, depositTypeData, subStatus, lastActiveTransactionDate);
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
}
}
@@ -415,6 +415,14 @@ final class GroupsApiResourceSwagger {
public String productName;
public GetGroupsGroupIdAccountsSavingStatus status;
public GetGroupsGroupIdAccountsSavingCurrency currency;
@Schema(example = "5000.00")
public java.math.BigDecimal accountBalance;
@Schema(example = "300.00")
public java.math.BigDecimal onHoldFunds;
@Schema(example = "200.00")
public java.math.BigDecimal savingsAmountOnHold;
@Schema(example = "4500.00")
public java.math.BigDecimal availableBalance;
public GetGroupsGroupIdAccountsSavingAccountType accountType;
}
@@ -26,6 +26,7 @@ import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
@@ -45,6 +46,7 @@ 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.LoanStatusChecker;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper;
import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper;
@@ -72,6 +74,7 @@ public class GroupSavingsIntegrationTest {
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 HOLD_AMOUNT = "300";
public static final String ACCOUNT_TYPE_GROUP = "GROUP";
private ResponseSpecification responseSpec;
@@ -1021,4 +1024,261 @@ public class GroupSavingsIntegrationTest {
"Should find at least one on-hold transaction with savingsClientName populated (group name)");
}
@Test
public void testGroupAccountAvailableBalance() {
this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
// Create a client
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec);
ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID);
Assertions.assertNotNull(clientID);
// Create a group and associate the client
Integer groupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true);
Assertions.assertNotNull(groupID);
// Create a savings product
final String minBalanceForInterestCalculation = null;
final String minRequiredBalance = null;
final String enforceMinRequiredBalance = "false";
final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE,
minBalanceForInterestCalculation, minRequiredBalance, enforceMinRequiredBalance);
Assertions.assertNotNull(savingsProductID);
// Apply for 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);
// Make a deposit to create a balance
Integer depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, DEPOSIT_AMOUNT,
SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID);
Assertions.assertNotNull(depositTransactionId);
// Get the account summary to verify balance
// Note: Account has minimum opening balance (1000) + deposit (2000) = 3000 total
HashMap summary = this.savingsAccountHelper.getSavingsSummary(savingsId);
Float expectedBalance = Float.parseFloat(MINIMUM_OPENING_BALANCE) + Float.parseFloat(DEPOSIT_AMOUNT);
assertEquals(expectedBalance, summary.get("accountBalance"), "Verifying Deposit Balance");
// Retrieve group accounts endpoint
final String GROUP_ACCOUNTS_URL = "/fineract-provider/api/v1/groups/" + groupID + "/accounts?" + Utils.TENANT_IDENTIFIER;
HashMap groupAccountsResponse = Utils.performServerGet(this.requestSpec, this.responseSpec, GROUP_ACCOUNTS_URL, "");
Assertions.assertNotNull(groupAccountsResponse);
// Verify savingsAccounts array exists and has our account
ArrayList<HashMap> savingsAccounts = (ArrayList<HashMap>) groupAccountsResponse.get("savingsAccounts");
Assertions.assertNotNull(savingsAccounts, "savingsAccounts array should be present");
Assertions.assertTrue(savingsAccounts.size() > 0, "savingsAccounts should contain at least one account");
// Find our savings account in the response
HashMap account = null;
for (HashMap acc : savingsAccounts) {
if (acc.get("id").equals(savingsId)) {
account = acc;
break;
}
}
Assertions.assertNotNull(account, "Savings account should be in the response");
// Verify accountBalance and availableBalance fields are present
Assertions.assertNotNull(account.get("accountBalance"), "accountBalance field should be present");
Assertions.assertNotNull(account.get("availableBalance"), "availableBalance field should be present");
// Parse accountBalance
BigDecimal accountBalance = new BigDecimal(account.get("accountBalance").toString());
// Parse hold fields (may be null if no holds exist)
BigDecimal onHoldFunds = account.get("onHoldFunds") != null ? new BigDecimal(account.get("onHoldFunds").toString())
: BigDecimal.ZERO;
BigDecimal savingsAmountOnHold = account.get("savingsAmountOnHold") != null
? new BigDecimal(account.get("savingsAmountOnHold").toString())
: BigDecimal.ZERO;
// Parse availableBalance
BigDecimal availableBalance = new BigDecimal(account.get("availableBalance").toString());
// Verify accountBalance matches expected total (minimum opening balance + deposit)
assertEquals(0, expectedBalance.compareTo(Float.parseFloat(accountBalance.toString())),
"accountBalance should equal minimum opening balance plus deposited amount");
// Since we haven't placed any holds, onHoldFunds and savingsAmountOnHold should be null or 0
assertEquals(0, BigDecimal.ZERO.compareTo(onHoldFunds), "onHoldFunds should be 0 when no holds are placed");
assertEquals(0, BigDecimal.ZERO.compareTo(savingsAmountOnHold), "savingsAmountOnHold should be 0 when no holds are placed");
// Verify calculation: availableBalance = accountBalance - onHoldFunds - savingsAmountOnHold
BigDecimal expectedAvailableBalance = accountBalance.subtract(onHoldFunds).subtract(savingsAmountOnHold);
assertEquals(0, expectedAvailableBalance.compareTo(availableBalance),
"availableBalance should equal accountBalance - onHoldFunds - savingsAmountOnHold");
// Verify availableBalance equals accountBalance when there are no holds
assertEquals(0, accountBalance.compareTo(availableBalance), "availableBalance should equal accountBalance when there are no holds");
}
@Test
public void testGroupAccountWithHold() {
this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
// Create a group
final Integer groupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true);
Assertions.assertNotNull(groupID);
// Create a savings product
final String minBalanceForInterestCalculation = null;
final String minRequiredBalance = null;
final String enforceMinRequiredBalance = "false";
final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE,
minBalanceForInterestCalculation, minRequiredBalance, enforceMinRequiredBalance);
Assertions.assertNotNull(savingsProductID);
// Apply for 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);
// Make a deposit to create a balance
Integer depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, DEPOSIT_AMOUNT,
SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID);
Assertions.assertNotNull(depositTransactionId);
// Place a hold on the account
Integer holdTransactionId = (Integer) this.savingsAccountHelper.holdAmountInSavingsAccount(savingsId, HOLD_AMOUNT, false,
SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID);
Assertions.assertNotNull(holdTransactionId);
// Retrieve group accounts endpoint
final String GROUP_ACCOUNTS_URL = "/fineract-provider/api/v1/groups/" + groupID + "/accounts?" + Utils.TENANT_IDENTIFIER;
HashMap groupAccountsResponse = Utils.performServerGet(this.requestSpec, this.responseSpec, GROUP_ACCOUNTS_URL, "");
Assertions.assertNotNull(groupAccountsResponse);
// Find our savings account in the response
ArrayList<HashMap> savingsAccounts = (ArrayList<HashMap>) groupAccountsResponse.get("savingsAccounts");
HashMap account = null;
for (HashMap acc : savingsAccounts) {
if (acc.get("id").equals(savingsId)) {
account = acc;
break;
}
}
Assertions.assertNotNull(account, "Savings account should be in the response");
// Parse fields
BigDecimal accountBalance = new BigDecimal(account.get("accountBalance").toString());
BigDecimal onHoldFunds = account.get("onHoldFunds") != null ? new BigDecimal(account.get("onHoldFunds").toString())
: BigDecimal.ZERO;
BigDecimal savingsAmountOnHold = account.get("savingsAmountOnHold") != null
? new BigDecimal(account.get("savingsAmountOnHold").toString())
: BigDecimal.ZERO;
BigDecimal availableBalance = new BigDecimal(account.get("availableBalance").toString());
// Verify the hold amount is reflected in savingsAmountOnHold
assertEquals(0, new BigDecimal(HOLD_AMOUNT).compareTo(savingsAmountOnHold), "savingsAmountOnHold should equal the hold amount");
// Verify the calculation is correct: availableBalance = accountBalance - onHoldFunds - savingsAmountOnHold
BigDecimal expectedAvailableBalance = accountBalance.subtract(onHoldFunds).subtract(savingsAmountOnHold);
assertEquals(0, expectedAvailableBalance.compareTo(availableBalance),
"availableBalance should equal accountBalance - onHoldFunds - savingsAmountOnHold");
}
/**
* Test that verifies group savings accounts can be used as guarantors when loan products have guarantee
* requirements configured with zero minimum percentages.
* <p>
* Group accounts work with guarantees when minimum percentages are 0%, avoiding the self-guarantee validation logic
* that expects guarantor.entityId to match loan.clientId (which fails for group accounts where client_id = null).
* <p>
* By using {@code withOnHoldFundDetails("0","0","0")}, we enable guarantee fund holds (isHoldGuaranteeFunds = true)
* but set all minimum percentage requirements to 0%. This allows:
* <ul>
* <li>Validation to run (validateGuarantorBusinessRules() is called)</li>
* <li>Group accounts to pass validation (no mandatory minimums to check)</li>
* <li>Automatic holds to be placed on guarantor accounts upon loan disbursement</li>
* </ul>
*/
@Test
public void testGroupAccountAsGuarantorWithGuaranteeHolds() {
this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
this.loanTransactionHelper = new LoanTransactionHelper(this.requestSpec, this.responseSpec);
final GuarantorHelper guarantorHelper = new GuarantorHelper(this.requestSpec, this.responseSpec);
// Create a borrower client
final Integer borrowerClientID = ClientHelper.createClient(this.requestSpec, this.responseSpec);
ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, borrowerClientID);
// Create a group and associate the borrower with it
Integer groupID = GroupHelper.createGroup(this.requestSpec, this.responseSpec, true);
Assertions.assertNotNull(groupID);
GroupHelper.associateClient(this.requestSpec, this.responseSpec, groupID.toString(), borrowerClientID.toString());
// Create a GROUP savings account (owned by group, not individual client)
final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, MINIMUM_OPENING_BALANCE, null, null,
"false");
final Integer guarantorSavingsId = this.savingsAccountHelper.applyForSavingsApplication(groupID, savingsProductID,
ACCOUNT_TYPE_GROUP);
Assertions.assertNotNull(guarantorSavingsId);
HashMap savingsStatusHashMap = this.savingsAccountHelper.approveSavings(guarantorSavingsId);
SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap);
savingsStatusHashMap = this.savingsAccountHelper.activateSavings(guarantorSavingsId);
SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap);
// Deposit funds into the group account
final String depositAmount = "10000";
Integer depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(guarantorSavingsId, depositAmount,
SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID);
Assertions.assertNotNull(depositTransactionId);
// Create loan product with guarantee requirements but zero minimum percentages
// This allows group accounts to be used as guarantors while enabling automatic holds
final String loanProductJSON = new LoanProductTestBuilder().withPrincipal("10000").withNumberOfRepayments("12")
.withRepaymentAfterEvery("1").withRepaymentTypeAsMonth().withinterestRatePerPeriod("2")
.withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance()
.withOnHoldFundDetails("0", "0", "0") // 0% mandatory, 0% self, 0% external
.build(null);
final Integer loanProductID = loanTransactionHelper.getLoanProductId(loanProductJSON);
Assertions.assertNotNull(loanProductID);
// Create a basic loan for the borrower
final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("10000").withLoanTermFrequency("12")
.withLoanTermFrequencyAsMonths().withNumberOfRepayments("12").withRepaymentEveryAfter("1")
.withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments()
.withInterestTypeAsDecliningBalance().withInterestCalculationPeriodTypeSameAsRepaymentPeriod()
.withSubmittedOnDate(SavingsAccountHelper.TRANSACTION_DATE)
.withExpectedDisbursementDate(SavingsAccountHelper.TRANSACTION_DATE_PLUS_ONE)
.build(borrowerClientID.toString(), loanProductID.toString(), null);
final Integer loanID = loanTransactionHelper.getLoanId(loanApplicationJSON);
Assertions.assertNotNull(loanID);
// Create a guarantor linking the group savings account to the loan
final String guaranteeAmount = "5000";
final String guarantorJSON = new GuarantorTestBuilder()
.existingCustomerWithGuaranteeAmount(String.valueOf(borrowerClientID), String.valueOf(guarantorSavingsId), guaranteeAmount)
.build();
Integer guarantorId = guarantorHelper.createGuarantor(loanID, guarantorJSON);
Assertions.assertNotNull(guarantorId, "Guarantor with group savings account created successfully");
// Approve the loan - THIS is when the hold is placed (not on disbursement!)
HashMap loanStatusHashMap = loanTransactionHelper.approveLoan(SavingsAccountHelper.TRANSACTION_DATE, loanID);
LoanStatusChecker.verifyLoanIsApproved(loanStatusHashMap);
// Verify the group savings account has an automatic hold equal to the guarantee amount after approval
HashMap savingsDetails = this.savingsAccountHelper.getSavingsDetails(guarantorSavingsId);
Object onHoldFundsObj = savingsDetails.get("onHoldFunds");
BigDecimal onHoldFunds = onHoldFundsObj != null ? new BigDecimal(onHoldFundsObj.toString()) : BigDecimal.ZERO;
final BigDecimal expectedHoldAmount = new BigDecimal(guaranteeAmount);
Assertions.assertEquals(expectedHoldAmount, onHoldFunds.setScale(0, RoundingMode.HALF_UP),
"Group account should have automatic guarantor hold equal to guarantee amount after loan approval");
}
}