Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
670217d
FINERACT-2732: type savings transaction search results as an ordered …
DeathGun44 Aug 2, 2026
4738790
FINERACT-2732: expose the savings product fields typed clients need
DeathGun44 Aug 3, 2026
c150be9
FINERACT-2732: extend the feign savings helpers for interest posting …
DeathGun44 Aug 2, 2026
d56ae09
FINERACT-2732: migrate savings interest posting and search tests to f…
DeathGun44 Aug 2, 2026
fbe9997
FINERACT-2732: extend the feign savings helpers for transaction reversal
DeathGun44 Aug 2, 2026
2829c7b
FINERACT-2732: migrate the savings balance-after-reversal test to feign
DeathGun44 Aug 2, 2026
a4c8eba
FINERACT-2732: migrate the savings recalculate-balance test to feign
DeathGun44 Aug 2, 2026
866af47
FINERACT-2732: migrate the savings product creation test to feign
DeathGun44 Aug 2, 2026
e6d6a3e
FINERACT-2732: migrate the savings accrual tests to feign
DeathGun44 Aug 2, 2026
c32dc81
FINERACT-2732: expose the fields typed clients need for post-interest…
DeathGun44 Aug 2, 2026
ab9f6fc
FINERACT-2732: migrate the savings datatable and interest posting tes…
DeathGun44 Aug 2, 2026
85a68d4
FINERACT-2732: migrate the savings interest posting job test to feign
DeathGun44 Aug 2, 2026
9c43455
FINERACT-2732: expose the note and transfer-fee fields typed clients …
DeathGun44 Aug 3, 2026
8a140bd
FINERACT-2732: extend the feign helpers for transfers and failing bat…
DeathGun44 Aug 3, 2026
93248a6
FINERACT-2732: migrate the savings transaction and transfer-fee tests…
DeathGun44 Aug 3, 2026
427e6c3
FINERACT-2732: assert scheduler jobs actually succeed
DeathGun44 Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,12 @@ private PutDataTablesAppTableIdDatatableIdResponse() {
public Long loanId;
@Schema(example = "1")
public Long resourceId;
/**
* Carried by the {@code CommandProcessingResult} whenever the datatable hangs off a transaction - a savings
* transaction datatable, for instance - but it was missing here, so generated clients could not read it.
*/
@Schema(example = "1")
public String transactionId;
public Map<String, Object> changes;
}

Expand All @@ -278,6 +284,11 @@ private DeleteDataTablesDatatableAppTableIdResponse() {

@Schema(example = "1")
public Long resourceId;
/**
* See {@link PutDataTablesAppTableIdDatatableIdResponse#transactionId}.
*/
@Schema(example = "1")
public String transactionId;
}

@Schema(description = "DeleteDataTablesDatatableAppTableIdDatatableIdResponse ")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.portfolio.TransactionEntryType;

Expand Down Expand Up @@ -188,7 +189,12 @@ private GetSavingsAccountChargesPaidByData() {}

@Schema(example = "2")
public Long total;
public Set<GetSavingsAccountTransactionsPageItem> content;
/**
* A page of search results is ordered by the request's {@code orderBy}/{@code sortOrder} (defaulting to most
* recent first), so it is documented as an ordered list. Declaring it as a set made generated clients drop that
* ordering. The serialized response is a JSON array either way, so the wire format is unchanged.
*/
public List<GetSavingsAccountTransactionsPageItem> content;
}

@Schema(description = "PostSavingsAccountTransactionsRequest")
Expand All @@ -209,11 +215,15 @@ private PostSavingsAccountTransactionsRequest() {}
@Schema(example = "true")
public Boolean isPostInterestAsOn;
@Schema(example = "true")
public Boolean postInterestManualOrAutomatic;
@Schema(example = "true")
public String lienAllowed;
@Schema(example = "String")
public String reasonForBlock;
@Schema(example = "1")
public Integer paymentTypeId;
@Schema(example = "A note about this transaction")
public String note;
}

@Schema(description = "PostSavingsAccountTransactionsResponse")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ private PutSavingsAccountsAccountIdRequest() {}
public String locale;
@Schema(example = "5.9999999999")
public Double nominalAnnualInterestRate;
@Schema(example = "true")
public Boolean withdrawalFeeForTransfers;
}

@Schema(description = "PutSavingsAccountsAccountIdResponse")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ private PostSavingsCharges() {}
public Boolean withHoldTax;
@Schema(example = "false")
public Boolean isDormancyTrackingActive;
/*
* The overdraft settings and the GL account mappings below have always been accepted by the endpoint - they are
* part of SAVINGS_PRODUCT_REQUEST_DATA_PARAMETERS - but were never documented, so a typed client could not
* create an accrual-based or overdraft-enabled product at all.
*/
@Schema(example = "10000")
public BigDecimal overdraftLimit;
@Schema(example = "21.0")
public BigDecimal nominalAnnualInterestRateOverdraft;
@Schema(example = "1")
public Long savingsReferenceAccountId;
@Schema(example = "1")
public Long savingsControlAccountId;
@Schema(example = "1")
public Long transfersInSuspenseAccountId;
@Schema(example = "1")
public Long interestOnSavingsAccountId;
@Schema(example = "1")
public Long writeOffAccountId;
@Schema(example = "1")
public Long incomeFromFeeAccountId;
@Schema(example = "1")
public Long incomeFromPenaltyAccountId;
@Schema(example = "1")
public Long incomeFromInterestId;
@Schema(example = "1")
public Long overdraftPortfolioControlId;
@Schema(example = "1")
public Long feesReceivableAccountId;
@Schema(example = "1")
public Long penaltiesReceivableAccountId;
@Schema(example = "1")
public Long interestPayableAccountId;
@Schema(example = "1")
public Long interestReceivableAccountId;
}

@Schema(description = "PostSavingsProductsResponse")
Expand All @@ -95,6 +130,11 @@ private PostSavingsProductsResponse() {}
public Long resourceId;
}

/**
* An update is validated against the same {@code SAVINGS_PRODUCT_REQUEST_DATA_PARAMETERS} set as a create, so it
* accepts the whole product body rather than the three fields that used to be documented here. The extra fields are
* listed so a typed client can repoint an accounting mapping or an overdraft setting without hand-building JSON.
*/
@Schema(description = "PutSavingsProductsProductIdRequest")
public static final class PutSavingsProductsProductIdRequest {

Expand All @@ -106,6 +146,68 @@ private PutSavingsProductsProductIdRequest() {}
public String locale;
@Schema(example = "5.73")
public Double interestRate;
@Schema(example = "Passbook Savings")
public String name;
@Schema(example = "PBSV")
public String shortName;
@Schema(example = "USD")
public String currencyCode;
@Schema(example = "2")
public Integer digitsAfterDecimal;
@Schema(example = "0")
public Integer inMultiplesOf;
@Schema(example = "5.0")
public Double nominalAnnualInterestRate;
@Schema(example = "10000.0")
public BigDecimal minRequiredOpeningBalance;
@Schema(example = "1")
public Integer interestCompoundingPeriodType;
@Schema(example = "4")
public Integer interestPostingPeriodType;
@Schema(example = "1")
public Integer interestCalculationType;
@Schema(example = "365")
public Integer interestCalculationDaysInYearType;
@Schema(example = "1")
public Integer accountingRule;
@Schema(example = "false")
public Boolean withdrawalFeeForTransfers;
@Schema(example = "false")
public Boolean enforceMinRequiredBalance;
@Schema(example = "false")
public Boolean allowOverdraft;
@Schema(example = "false")
public Boolean withHoldTax;
@Schema(example = "10000")
public BigDecimal overdraftLimit;
@Schema(example = "21.0")
public BigDecimal nominalAnnualInterestRateOverdraft;
@Schema(example = "1")
public Long savingsReferenceAccountId;
@Schema(example = "1")
public Long savingsControlAccountId;
@Schema(example = "1")
public Long transfersInSuspenseAccountId;
@Schema(example = "1")
public Long interestOnSavingsAccountId;
@Schema(example = "1")
public Long writeOffAccountId;
@Schema(example = "1")
public Long incomeFromFeeAccountId;
@Schema(example = "1")
public Long incomeFromPenaltyAccountId;
@Schema(example = "1")
public Long incomeFromInterestId;
@Schema(example = "1")
public Long overdraftPortfolioControlId;
@Schema(example = "1")
public Long feesReceivableAccountId;
@Schema(example = "1")
public Long penaltiesReceivableAccountId;
@Schema(example = "1")
public Long interestPayableAccountId;
@Schema(example = "1")
public Long interestReceivableAccountId;
}

@Schema(description = "PutSavingsProductsProductIdResponse")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,125 +19,85 @@
package org.apache.fineract.integrationtests;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.Month;
import java.util.List;
import org.apache.fineract.client.models.PostSavingsProductsResponse;
import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
import org.apache.fineract.client.models.SavingsAccountTransactionData;
import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.CommonConstants;
import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
import org.apache.fineract.integrationtests.common.Utils;
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.client.feign.FeignSavingsTestBase;
import org.apache.fineract.integrationtests.client.feign.modules.SavingsRequestBuilders;
import org.apache.fineract.integrationtests.client.feign.modules.SavingsTestData;
import org.apache.fineract.integrationtests.client.feign.modules.SavingsTestValidators;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@SuppressWarnings({ "rawtypes", "unused", "unchecked" })
@ExtendWith({ SavingsTestLifecycleExtension.class })
public class FlexibleSavingsInterestPostingIntegrationTest {

private static final Logger LOG = LoggerFactory.getLogger(FlexibleSavingsInterestPostingIntegrationTest.class);
public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL";

private ResponseSpecification responseSpec;
private RequestSpecification requestSpec;
private SavingsProductHelper savingsProductHelper;
private SavingsAccountHelper savingsAccountHelper;
private GlobalConfigurationHelper globalConfigurationHelper;

@BeforeEach
public void setup() {
Utils.initializeRESTAssured();
this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
this.savingsProductHelper = new SavingsProductHelper();
this.globalConfigurationHelper = new GlobalConfigurationHelper();
}

public class FlexibleSavingsInterestPostingIntegrationTest extends FeignSavingsTestBase {

private static final String START_DATE = "01 December 2013";
private static final long APRIL = 4L;

// 1st Dec 13 to 31st March 14 - 365 days, daily compounding using daily balance
// 33.7016 obtained from formula in excel provided by Subramanya
private static final BigDecimal EXPECTED_INTEREST_POSTED = new BigDecimal("33.7016");
private static final LocalDate EXPECTED_INTEREST_POSTING_DATE = LocalDate.of(2014, Month.MARCH, 31);

@Test
public void testSavingsInterestPostingAtPeriodEnd() {
// client activation, savings activation and 1st transaction date
final String startDate = "01 December 2013";
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate);
Assertions.assertNotNull(clientID);

// Configuring global config flags
configureInterestPosting(true, 4L);

final Integer savingsId = createSavingsAccount(clientID, startDate);

this.savingsAccountHelper.depositToSavingsAccount(savingsId, "1000", startDate, CommonConstants.RESPONSE_RESOURCE_ID);

/***
* Perform Post interest transaction and verify the posted transaction date
*/
this.savingsAccountHelper.postInterestForSavings(savingsId);
HashMap accountDetails = this.savingsAccountHelper.getSavingsDetails(savingsId);
ArrayList<HashMap<String, Object>> transactions = (ArrayList<HashMap<String, Object>>) accountDetails.get("transactions");
HashMap<String, Object> interestPostingTransaction = transactions.get(transactions.size() - 2);
for (Map.Entry<String, Object> entry : interestPostingTransaction.entrySet()) {
LOG.info("{} - {}", entry.getKey(), String.valueOf(entry.getValue()));
}
// 1st Dec 13 to 31st March 14 - 365 days, daily compounding using daily
// balance
// 33.7016 obtained from formula in excel provided by Subramanya
assertEquals("33.7016", interestPostingTransaction.get("amount").toString(), "Equality check for interest posted amount");
assertEquals("[2014, 3, 31]", interestPostingTransaction.get("date").toString(), "Date check for Interest Posting transaction");
Long clientId = createClient(START_DATE);
assertNotNull(clientId);

configureInterestPosting(true, APRIL);

Long savingsId = createSavingsAccount(clientId, START_DATE);

deposit(savingsId, "1000", START_DATE);

savingsHelper.postInterest(savingsId);

List<SavingsAccountTransactionData> transactions = savingsTransactionHelper.getTransactions(savingsId);
SavingsAccountTransactionData interestPostingTransaction = transactions.get(transactions.size() - 2);
SavingsTestValidators.verifyIsInterestPosting(interestPostingTransaction);

SavingsTestValidators.verifyAmount(EXPECTED_INTEREST_POSTED, interestPostingTransaction.getAmount(),
"Equality check for interest posted amount");
assertEquals(EXPECTED_INTEREST_POSTING_DATE, interestPostingTransaction.getDate(), "Date check for Interest Posting transaction");
}

private Integer createSavingsAccount(final Integer clientID, final String startDate) {
final Integer savingsProductID = createSavingsProduct();
Assertions.assertNotNull(savingsProductID);
final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientID, savingsProductID,
ACCOUNT_TYPE_INDIVIDUAL, startDate);
Assertions.assertNotNull(savingsId);
HashMap savingsStatusHashMap = this.savingsAccountHelper.approveSavingsOnDate(savingsId, startDate);
SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap);
savingsStatusHashMap = this.savingsAccountHelper.activateSavingsAccount(savingsId, startDate);
SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap);
private Long createSavingsAccount(final Long clientId, final String startDate) {
PostSavingsProductsResponse savingsProduct = createSavingsProductAnnualPosting();
assertNotNull(savingsProduct.getResourceId());

Long savingsId = submitSavingsApplication(clientId, savingsProduct.getResourceId(), startDate).getSavingsId();
assertNotNull(savingsId);

approveSavings(savingsId, startDate);
SavingsTestValidators.verifySavingsIsApproved(savingsHelper.getSavingsStatus(savingsId));

activateSavings(savingsId, startDate);
SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId));
return savingsId;
}

private void configureInterestPosting(final Boolean periodEndEnable, final Long financialYearBeginningMonth) {
// Updating flag for interest posting at period end
String periodEndConfigName = GlobalConfigurationConstants.SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END;
globalConfigurationHelper.updateGlobalConfiguration(periodEndConfigName,
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END,
new PutGlobalConfigurationsRequest().enabled(periodEndEnable));

// Updating value for financial year beginning month
String financialYearBeginningConfigName = GlobalConfigurationConstants.FINANCIAL_YEAR_BEGINNING_MONTH;
globalConfigurationHelper.updateGlobalConfiguration(financialYearBeginningConfigName,
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FINANCIAL_YEAR_BEGINNING_MONTH,
new PutGlobalConfigurationsRequest().value(financialYearBeginningMonth));
}

private Integer createSavingsProduct() {
final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily()
.withInterestPostingPeriodTypeAsAnnual().withInterestCalculationPeriodTypeAsDailyBalance().build();
return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec);
private PostSavingsProductsResponse createSavingsProductAnnualPosting() {
return createSavingsProduct(SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY,
SavingsTestData.InterestPostingPeriodType.ANNUAL, SavingsTestData.InterestCalculationType.DAILY_BALANCE));
}

// Reset configuration fields
@AfterEach
public void tearDown() {
globalConfigurationHelper.resetAllDefaultGlobalConfigurations();
globalConfigurationHelper.verifyAllDefaultGlobalConfigurations();
}

}
Loading
Loading