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
@@ -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");
}
}