diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResourceSwagger.java index b8b5b0c350b..b04443dc7c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResourceSwagger.java @@ -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 changes; } @@ -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 ") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResourceSwagger.java index 4f2354883ab..c0177a6c580 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResourceSwagger.java @@ -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; @@ -188,7 +189,12 @@ private GetSavingsAccountChargesPaidByData() {} @Schema(example = "2") public Long total; - public Set 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 content; } @Schema(description = "PostSavingsAccountTransactionsRequest") @@ -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") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceSwagger.java index 3d2a19c4f2a..1468d5195a4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResourceSwagger.java @@ -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") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResourceSwagger.java index 6760b9361d1..654e570cda7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsProductsApiResourceSwagger.java @@ -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") @@ -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 { @@ -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") diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/FlexibleSavingsInterestPostingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/FlexibleSavingsInterestPostingIntegrationTest.java index 87917d60237..057e9dbe34c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/FlexibleSavingsInterestPostingIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/FlexibleSavingsInterestPostingIntegrationTest.java @@ -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> transactions = (ArrayList>) accountDetails.get("transactions"); - HashMap interestPostingTransaction = transactions.get(transactions.size() - 2); - for (Map.Entry 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 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(); } - } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountBalanceCheckAfterReversalTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountBalanceCheckAfterReversalTest.java index 065cfe50645..902d0238577 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountBalanceCheckAfterReversalTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountBalanceCheckAfterReversalTest.java @@ -18,128 +18,93 @@ */ package org.apache.fineract.integrationtests; -import static org.junit.jupiter.api.Assertions.assertEquals; - -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.HashMap; -import java.util.List; -import org.apache.fineract.integrationtests.common.ClientHelper; -import org.apache.fineract.integrationtests.common.CommonConstants; -import org.apache.fineract.integrationtests.common.SchedulerJobHelper; -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.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +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.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccountBalanceCheckAfterReversalTest { - - public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; - public static final String START_DATE = "10 April 2022"; - private ResponseSpecification responseSpec; - private RequestSpecification requestSpec; - private SavingsProductHelper savingsProductHelper; - private SavingsAccountHelper savingsAccountHelper; - private SchedulerJobHelper scheduleJobHelper; - - @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.scheduleJobHelper = new SchedulerJobHelper(requestSpec); - } - @SuppressWarnings("unchecked") +public class SavingsAccountBalanceCheckAfterReversalTest extends FeignSavingsTestBase { + + private static final String START_DATE = "10 April 2022"; + private static final BigDecimal OVERDRAFT_LIMIT = new BigDecimal("10000"); + private static final String INSUFFICIENT_BALANCE_ERROR = "error.msg.savingsaccount.transaction.insufficient.account.balance"; + @Test public void testSavingsBalanceAfterWithdrawal() { - SavingsAccountHelper savingsAccountHelperValidationError = new SavingsAccountHelper(this.requestSpec, - new ResponseSpecBuilder().build()); - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, START_DATE); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID); - Integer depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, "10000", START_DATE, - CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.reverseSavingsAccountTransaction(savingsId, depositTransactionId); - HashMap reversedDepositTransaction = this.savingsAccountHelper.getSavingsTransaction(savingsId, depositTransactionId); - Assertions.assertTrue((Boolean) reversedDepositTransaction.get("reversed")); - HashMap summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - Float balance = Float.parseFloat("0.0"); - assertEquals(balance, summary.get("accountBalance"), "Verifying opening Balance is 0"); - List error = (List) savingsAccountHelperValidationError.withdrawalFromSavingsAccount(savingsId, "100", START_DATE, - CommonConstants.RESPONSE_ERROR); - assertEquals("error.msg.savingsaccount.transaction.insufficient.account.balance", - error.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE)); + Long clientId = createClient(START_DATE); + assertNotNull(clientId); + + Long savingsId = createSavingsAccountDailyPosting(clientId); + + Long depositTransactionId = deposit(savingsId, "10000", START_DATE).getResourceId(); + savingsTransactionHelper.reverseTransaction(savingsId, depositTransactionId); + + SavingsAccountTransactionData reversedDeposit = savingsTransactionHelper.getTransaction(savingsId, depositTransactionId); + assertTrue(reversedDeposit.getReversed(), "Deposit transaction was not reversed"); + + SavingsTestValidators.verifyAmount(BigDecimal.ZERO, savingsHelper.getSavingsSummary(savingsId).getAccountBalance(), + "Verifying opening Balance is 0"); + + CallFailedRuntimeException error = savingsTransactionHelper.withdrawExpectingError(savingsId, "100", START_DATE); + SavingsTestValidators.verifyFirstErrorCode(INSUFFICIENT_BALANCE_ERROR, error); } @Test public void testSavingsBalanceWithOverDraftAfterWithdrawal() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, START_DATE); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPostingWithOverDraft(clientID); - Integer withdrawalTransactionId = (Integer) this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "1000", START_DATE, - CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.reverseSavingsAccountTransaction(savingsId, withdrawalTransactionId); - HashMap reversedWithdrawalTransaction = this.savingsAccountHelper.getSavingsTransaction(savingsId, withdrawalTransactionId); - Assertions.assertTrue((Boolean) reversedWithdrawalTransaction.get("reversed")); - HashMap summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - Float balance = Float.parseFloat("0.0"); - assertEquals(balance, summary.get("accountBalance"), "Verifying Balance is 0"); - Integer withdrawalAfterReversalTransactionId = (Integer) savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "500", - START_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - balance = Float.parseFloat("-500.0"); - assertEquals(balance, summary.get("accountBalance"), "Verifying Balance is -500"); - } + Long clientId = createClient(START_DATE); + assertNotNull(clientId); - private Integer createSavingsAccountDailyPosting(final Integer clientID) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - Assertions.assertNotNull(savingsProductID); - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientID, savingsProductID, - ACCOUNT_TYPE_INDIVIDUAL, START_DATE); - Assertions.assertNotNull(savingsId); - HashMap savingsStatusHashMap = this.savingsAccountHelper.approveSavingsOnDate(savingsId, START_DATE); - SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap); - savingsStatusHashMap = this.savingsAccountHelper.activateSavingsAccount(savingsId, START_DATE); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); - return savingsId; + Long savingsId = createSavingsAccountDailyPostingWithOverDraft(clientId); + + Long withdrawalTransactionId = withdraw(savingsId, "1000", START_DATE).getResourceId(); + savingsTransactionHelper.reverseTransaction(savingsId, withdrawalTransactionId); + + SavingsAccountTransactionData reversedWithdrawal = savingsTransactionHelper.getTransaction(savingsId, withdrawalTransactionId); + assertTrue(reversedWithdrawal.getReversed(), "Withdrawal transaction was not reversed"); + + SavingsTestValidators.verifyAmount(BigDecimal.ZERO, savingsHelper.getSavingsSummary(savingsId).getAccountBalance(), + "Verifying Balance is 0"); + + withdraw(savingsId, "500", START_DATE); + SavingsTestValidators.verifyAmount(new BigDecimal("-500"), savingsHelper.getSavingsSummary(savingsId).getAccountBalance(), + "Verifying Balance is -500"); } - private Integer createSavingsAccountDailyPostingWithOverDraft(final Integer clientID) { - final Integer savingsProductID = createSavingsProductDailyPostingWithOverDraft(); - Assertions.assertNotNull(savingsProductID); - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientID, savingsProductID, - ACCOUNT_TYPE_INDIVIDUAL, START_DATE); - Assertions.assertNotNull(savingsId); - HashMap savingsStatusHashMap = this.savingsAccountHelper.approveSavingsOnDate(savingsId, START_DATE); - SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap); - savingsStatusHashMap = this.savingsAccountHelper.activateSavingsAccount(savingsId, START_DATE); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); - return savingsId; + private Long createSavingsAccountDailyPosting(final Long clientId) { + Long productId = createSavingsProduct(dailyPostingProduct()).getResourceId(); + assertNotNull(productId); + return approveAndActivate(clientId, productId); } - private Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private Long createSavingsAccountDailyPostingWithOverDraft(final Long clientId) { + Long productId = savingsProductHelper + .createSavingsProduct(dailyPostingProduct().allowOverdraft(true).overdraftLimit(OVERDRAFT_LIMIT)).getResourceId(); + assertNotNull(productId); + return approveAndActivate(clientId, productId); } - private Integer createSavingsProductDailyPostingWithOverDraft() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().withOverDraft("10000").build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private PostSavingsProductsRequest dailyPostingProduct() { + return SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, + SavingsTestData.InterestPostingPeriodType.DAILY, SavingsTestData.InterestCalculationType.DAILY_BALANCE); } + private Long approveAndActivate(final Long clientId, final Long productId) { + Long savingsId = submitSavingsApplication(clientId, productId, START_DATE).getSavingsId(); + assertNotNull(savingsId); + + approveSavings(savingsId, START_DATE); + SavingsTestValidators.verifySavingsIsApproved(savingsHelper.getSavingsStatus(savingsId)); + + activateSavings(savingsId, START_DATE); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId)); + return savingsId; + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountForceWithdrawalTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountForceWithdrawalTest.java index d5f2f48b889..1f88cfa3f45 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountForceWithdrawalTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountForceWithdrawalTest.java @@ -18,96 +18,67 @@ */ package org.apache.fineract.integrationtests; -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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; import org.apache.fineract.client.models.GlobalConfigurationPropertyData; -import org.apache.fineract.client.models.PostSavingsAccountTransactionsRequest; import org.apache.fineract.client.models.PostSavingsAccountTransactionsResponse; +import org.apache.fineract.client.models.PostSavingsProductsResponse; import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; -import org.apache.fineract.integrationtests.common.ClientHelper; -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.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; -public class SavingsAccountForceWithdrawalTest { - - private ResponseSpecification responseSpec; - private RequestSpecification requestSpec; - private SavingsProductHelper savingsProductHelper; - private SavingsAccountHelper savingsAccountHelper; - private GlobalConfigurationHelper globalConfigurationHelper; +public class SavingsAccountForceWithdrawalTest extends FeignSavingsTestBase { - @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(); - } + private static final String DEPOSIT_DATE = "04 March 2013"; + private static final String WITHDRAWAL_DATE = "05 March 2013"; + private static final long FORCE_WITHDRAWAL_LIMIT = 5000L; + private static final long DISABLED_LIMIT = 0L; @Test public void testForceWithdrawal() { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT, new PutGlobalConfigurationsRequest().enabled(true)); globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT_LIMIT, - new PutGlobalConfigurationsRequest().value(5000L).enabled(true)); + new PutGlobalConfigurationsRequest().value(FORCE_WITHDRAWAL_LIMIT).enabled(true)); GlobalConfigurationPropertyData config = globalConfigurationHelper .getGlobalConfigurationByName(GlobalConfigurationConstants.FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT_LIMIT); - Assertions.assertEquals(5000L, config.getValue()); + assertEquals(FORCE_WITHDRAWAL_LIMIT, config.getValue()); - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); - final Integer savingsProductId = createSavingsProductDailyPosting(); - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, "INDIVIDUAL"); - this.savingsAccountHelper.approveSavings(savingsId); - this.savingsAccountHelper.activateSavings(savingsId); + Long clientId = createClient(); + assertNotNull(clientId); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", "04 March 2013", null); + PostSavingsProductsResponse savingsProduct = createSavingsProduct( + SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, + SavingsTestData.InterestPostingPeriodType.DAILY, SavingsTestData.InterestCalculationType.DAILY_BALANCE)); + assertNotNull(savingsProduct.getResourceId()); - PostSavingsAccountTransactionsRequest request = new PostSavingsAccountTransactionsRequest() // - .locale("en") // - .dateFormat("dd MMMM yyyy") // - .transactionDate("05 March 2013") // - .transactionAmount(java.math.BigDecimal.valueOf(200.0)) // - .paymentTypeId(1); + Long savingsId = submitSavingsApplication(clientId, savingsProduct.getResourceId(), DEPOSIT_DATE).getSavingsId(); + approveSavings(savingsId, DEPOSIT_DATE); + activateSavings(savingsId, DEPOSIT_DATE); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId)); - retrofit2.Response response = this.savingsAccountHelper - .forceWithdrawalFromSavingsAccount(savingsId.longValue(), request); + deposit(savingsId, "100", DEPOSIT_DATE); - Assertions.assertTrue(response.isSuccessful(), () -> "Force withdrawal failed with body: " + getErrorBody(response)); - } - - private Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); - } + PostSavingsAccountTransactionsResponse response = savingsTransactionHelper.forceWithdraw(savingsId, "200", WITHDRAWAL_DATE); - private String getErrorBody(retrofit2.Response response) { - try { - return response.errorBody() != null ? response.errorBody().string() : "No error body"; - } catch (Exception e) { - return "Failed to read error body: " + e.getMessage(); - } + assertNotNull(response.getResourceId(), "Force withdrawal did not create a transaction"); + SavingsTestValidators.verifyAmount(new BigDecimal("-100"), savingsHelper.getSavingsSummary(savingsId).getAccountBalance(), + "Balance after forcing a withdrawal past the available balance"); } @AfterEach - public void tearDown() { + public void resetForceWithdrawalConfiguration() { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT, new PutGlobalConfigurationsRequest().enabled(false)); globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT_LIMIT, - new PutGlobalConfigurationsRequest().value(0L).enabled(false)); + new PutGlobalConfigurationsRequest().value(DISABLED_LIMIT).enabled(false)); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountRecalculateBalanceTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountRecalculateBalanceTest.java index 09f61b79748..5b1a683c978 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountRecalculateBalanceTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountRecalculateBalanceTest.java @@ -18,213 +18,140 @@ */ package org.apache.fineract.integrationtests; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +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.math.BigDecimal; -import java.util.HashMap; -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.PaymentTypeHelper; -import org.apache.fineract.integrationtests.common.SchedulerJobHelper; -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.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +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.Order; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -/** - * Client Savings Integration Test for checking Savings Application. - */ -@SuppressWarnings({ "rawtypes" }) -@Order(2) -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccountRecalculateBalanceTest { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsAccountRecalculateBalanceTest.class); - public static final String DEPOSIT_AMOUNT = "2000"; - 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 ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; - public static final String DATE_FORMAT = "dd MMMM yyyy"; - - private ResponseSpecification responseSpec; - private RequestSpecification requestSpec; - private SavingsAccountHelper savingsAccountHelper; - private SavingsProductHelper savingsProductHelper; - private SchedulerJobHelper scheduleJobHelper; - private PaymentTypeHelper paymentTypeHelper; - 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.requestSpec.header("Fineract-Platform-TenantId", "default"); - this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); - this.paymentTypeHelper = new PaymentTypeHelper(); - this.globalConfigurationHelper = new GlobalConfigurationHelper(); - } +public class SavingsAccountRecalculateBalanceTest extends FeignSavingsTestBase { + + private static final String SUBMITTED_ON_DATE = "08 January 2013"; + private static final String APPROVED_ON_DATE = "09 January 2013"; + private static final String TRANSACTION_DATE = "01 March 2013"; + + private static final BigDecimal TRANSACTION_AMOUNT = new BigDecimal("100"); + private static final BigDecimal HOLD_AMOUNT = new BigDecimal("50"); + private static final BigDecimal OVERDRAFT_LIMIT = new BigDecimal("500.0"); + private static final String HOLD_REASON = "unUsualActivity"; + private static final Double NO_INTEREST = 0.0; @Test public void testSavingsAccountDepositAfterNegativeHoldAmount() { - this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); - - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); - ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); - - final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, "0", null, false, true, false, null); - Assertions.assertNotNull(savingsProductID); - - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductID, ACCOUNT_TYPE_INDIVIDUAL); - this.savingsAccountHelper.approveSavings(savingsId); - HashMap savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); - - float balance = 0F; - float transactionAmount = 100F; - Integer depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, - String.valueOf(transactionAmount), SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(depositTransactionId); - balance = balance + transactionAmount; - HashMap summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after deposit"); - Integer withdrawalTransactionId = (Integer) this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, - String.valueOf(transactionAmount), SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(withdrawalTransactionId); - balance = balance - transactionAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after withdrawal"); - - float holdAmount = 50F; - Integer holdTransactionId = (Integer) this.savingsAccountHelper.holdAmountInSavingsAccount(savingsId, String.valueOf(holdAmount), - false, SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(holdTransactionId); - balance = balance - holdAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after hold amount"); - this.savingsAccountHelper.releaseAmount(savingsId, holdTransactionId); - balance = balance + holdAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after release amount"); - - depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, String.valueOf(transactionAmount), - SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(depositTransactionId); - balance = balance + transactionAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after hold-release-deposit"); + final Long savingsId = createActiveOverdraftSavingsAccount(null); + + BigDecimal balance = BigDecimal.ZERO; + + deposit(savingsId, TRANSACTION_AMOUNT.toPlainString(), TRANSACTION_DATE); + balance = balance.add(TRANSACTION_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after deposit"); + + withdraw(savingsId, TRANSACTION_AMOUNT.toPlainString(), TRANSACTION_DATE); + balance = balance.subtract(TRANSACTION_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after withdrawal"); + + final Long holdTransactionId = holdAmount(savingsId); + balance = balance.subtract(HOLD_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after hold amount"); + + savingsTransactionHelper.releaseAmount(savingsId, holdTransactionId); + balance = balance.add(HOLD_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after release amount"); + + deposit(savingsId, TRANSACTION_AMOUNT.toPlainString(), TRANSACTION_DATE); + balance = balance.add(TRANSACTION_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after hold-release-deposit"); } @Test public void testSavingsAccountDepositAfterNegativeHoldAmountNoInterest() { - this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); - - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); - ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); - - final Integer savingsProductID = createSavingsProduct(this.requestSpec, this.responseSpec, "0", null, false, true, false, - BigDecimal.ZERO); - Assertions.assertNotNull(savingsProductID); - - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductID, ACCOUNT_TYPE_INDIVIDUAL); - this.savingsAccountHelper.approveSavings(savingsId); - HashMap savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); - - float balance = 0F; - float transactionAmount = 100F; - Integer depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, - String.valueOf(transactionAmount), SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(depositTransactionId); - balance = balance + transactionAmount; - HashMap summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after deposit"); - HashMap depositTransaction = savingsAccountHelper.getTransactionDetails(savingsId, depositTransactionId); - assertEquals(balance, depositTransaction.get("runningBalance"), "Verifying Running Balance of deposit"); - Integer withdrawalTransactionId = (Integer) this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, - String.valueOf(transactionAmount), SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(withdrawalTransactionId); - balance = balance - transactionAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after withdrawal"); - HashMap withdrawalTransaction = savingsAccountHelper.getTransactionDetails(savingsId, withdrawalTransactionId); - assertEquals(balance, withdrawalTransaction.get("runningBalance"), "Verifying Running Balance of withdraw"); - - float holdAmount = 50F; - Integer holdTransactionId = (Integer) this.savingsAccountHelper.holdAmountInSavingsAccount(savingsId, String.valueOf(holdAmount), - false, SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(holdTransactionId); - balance = balance - holdAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after hold amount"); - Integer releaseTransactionId = this.savingsAccountHelper.releaseAmount(savingsId, holdTransactionId); - Assertions.assertNotNull(releaseTransactionId); - balance = balance + holdAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after release amount"); - - depositTransactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, String.valueOf(transactionAmount), - SavingsAccountHelper.TRANSACTION_DATE, CommonConstants.RESPONSE_RESOURCE_ID); - Assertions.assertNotNull(depositTransactionId); - balance = balance + transactionAmount; - summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - assertEquals(balance, summary.get("availableBalance"), "Verifying Balance after hold-release-deposit"); - depositTransaction = savingsAccountHelper.getTransactionDetails(savingsId, depositTransactionId); + final Long savingsId = createActiveOverdraftSavingsAccount(NO_INTEREST); + + BigDecimal balance = BigDecimal.ZERO; + + Long depositTransactionId = deposit(savingsId, TRANSACTION_AMOUNT.toPlainString(), TRANSACTION_DATE).getResourceId(); + assertNotNull(depositTransactionId); + balance = balance.add(TRANSACTION_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after deposit"); + verifyRunningBalance(savingsId, depositTransactionId, balance, "Verifying Running Balance of deposit"); + + final Long withdrawalTransactionId = withdraw(savingsId, TRANSACTION_AMOUNT.toPlainString(), TRANSACTION_DATE).getResourceId(); + assertNotNull(withdrawalTransactionId); + balance = balance.subtract(TRANSACTION_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after withdrawal"); + verifyRunningBalance(savingsId, withdrawalTransactionId, balance, "Verifying Running Balance of withdraw"); + + final Long holdTransactionId = holdAmount(savingsId); + balance = balance.subtract(HOLD_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after hold amount"); + + final Long releaseTransactionId = savingsTransactionHelper.releaseAmount(savingsId, holdTransactionId).getResourceId(); + assertNotNull(releaseTransactionId); + balance = balance.add(HOLD_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after release amount"); + + depositTransactionId = deposit(savingsId, TRANSACTION_AMOUNT.toPlainString(), TRANSACTION_DATE).getResourceId(); + assertNotNull(depositTransactionId); + balance = balance.add(TRANSACTION_AMOUNT); + verifyAvailableBalance(savingsId, balance, "Verifying Balance after hold-release-deposit"); // this is a backdated transaction and so listed before the release transaction - assertEquals(balance - holdAmount, depositTransaction.get("runningBalance"), + verifyRunningBalance(savingsId, depositTransactionId, balance.subtract(HOLD_AMOUNT), "Verifying Running Balance of deposit negative balance"); - HashMap releaseTransaction = savingsAccountHelper.getTransactionDetails(savingsId, releaseTransactionId); - assertFalse((Boolean) releaseTransaction.get("reversed"), "Verifying release transaction with overdraft is not reversed"); - assertEquals(balance, releaseTransaction.get("runningBalance"), "Verifying Running Balance"); + final SavingsAccountTransactionData releaseTransaction = savingsTransactionHelper.getTransaction(savingsId, releaseTransactionId); + assertFalse(releaseTransaction.getReversed(), "Verifying release transaction with overdraft is not reversed"); + SavingsTestValidators.verifyAmount(balance, releaseTransaction.getRunningBalance(), "Verifying Running Balance"); } - // LienAtProductLevel - private Integer createSavingsProduct(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, - final String minOpenningBalance, String minBalanceForInterestCalculation, final boolean enforceMinRequiredBalance, - final boolean allowOverDraft, final boolean lienAllowed, BigDecimal interestRate) { + private Long holdAmount(final Long savingsId) { + final Long holdTransactionId = savingsTransactionHelper + .holdAmount(savingsId, HOLD_AMOUNT.toPlainString(), TRANSACTION_DATE, HOLD_REASON).getResourceId(); + assertNotNull(holdTransactionId); + return holdTransactionId; + } - LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT WITH LIEN---------------------------------------"); - SavingsProductHelper savingsProductHelper = new SavingsProductHelper(); - if (lienAllowed) { - final String maxAllowedLienLimit = "2000.0"; - savingsProductHelper.withLienAllowed(maxAllowedLienLimit); - } - if (enforceMinRequiredBalance) { - final String minRequiredBalance = "100.0"; - savingsProductHelper.withMinRequiredBalance(minRequiredBalance); - savingsProductHelper.withEnforceMinRequiredBalance("true"); - } - if (allowOverDraft) { - final String overDraftLimit = "500.0"; - savingsProductHelper.withOverDraft(overDraftLimit); - } - if (interestRate != null) { - savingsProductHelper.withNominalAnnualInterestRate(interestRate); - } - final String savingsProductJSON = savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() - .withMinBalanceForInterestCalculation(minBalanceForInterestCalculation).withMinimumOpenningBalance(minOpenningBalance) - .build(); + private void verifyAvailableBalance(final Long savingsId, final BigDecimal expected, final String message) { + SavingsTestValidators.verifyAmount(expected, savingsHelper.getSavingsSummary(savingsId).getAvailableBalance(), message); + } + + private void verifyRunningBalance(final Long savingsId, final Long transactionId, final BigDecimal expected, final String message) { + SavingsTestValidators.verifyAmount(expected, savingsTransactionHelper.getTransaction(savingsId, transactionId).getRunningBalance(), + message); + } - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private Long createActiveOverdraftSavingsAccount(final Double nominalAnnualInterestRate) { + final Long clientId = createClient(); + assertNotNull(clientId); + + final Long productId = createOverdraftSavingsProduct(nominalAnnualInterestRate); + assertNotNull(productId); + + final Long savingsId = submitSavingsApplication(clientId, productId, SUBMITTED_ON_DATE).getSavingsId(); + assertNotNull(savingsId); + + approveSavings(savingsId, APPROVED_ON_DATE); + activateSavings(savingsId, TRANSACTION_DATE); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId)); + return savingsId; + } + + private Long createOverdraftSavingsProduct(final Double nominalAnnualInterestRate) { + final PostSavingsProductsRequest request = SavingsRequestBuilders + .savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, SavingsTestData.InterestPostingPeriodType.MONTHLY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE) + .minRequiredOpeningBalance(BigDecimal.ZERO)// + .allowOverdraft(true); + if (nominalAnnualInterestRate != null) { + request.nominalAnnualInterestRate(nominalAnnualInterestRate); + } + return savingsProductHelper.createSavingsProduct(request.overdraftLimit(OVERDRAFT_LIMIT)).getResourceId(); } @AfterEach diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionDatatableIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionDatatableIntegrationTest.java index 89ebaa4f96d..81fe2be2ded 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionDatatableIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionDatatableIntegrationTest.java @@ -22,231 +22,145 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.google.gson.Gson; -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.HashMap; import java.util.List; +import java.util.Map; import org.apache.fineract.client.models.GetDataTablesResponse; import org.apache.fineract.client.models.PostColumnHeaderData; import org.apache.fineract.client.models.PostDataTablesRequest; import org.apache.fineract.client.models.PostDataTablesResponse; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.PutDataTablesAppTableIdDatatableIdResponse; import org.apache.fineract.client.models.PutDataTablesRequest; import org.apache.fineract.client.models.PutDataTablesRequestAddColumns; import org.apache.fineract.client.models.PutDataTablesResponse; import org.apache.fineract.client.models.ResultsetColumnHeaderData; import org.apache.fineract.infrastructure.dataqueries.data.EntityTables; -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.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.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.common.system.DatatableHelper; 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; -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccountTransactionDatatableIntegrationTest { +public class SavingsAccountTransactionDatatableIntegrationTest extends FeignSavingsTestBase { private static final String SAVINGS_TRANSACTION_APP_TABLE_NAME = EntityTables.SAVINGS_TRANSACTION.getName(); - public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; - final String startDate = "01 Jun 2023"; - final String firstDepositDate = "05 Jun 2023"; - private RequestSpecification requestSpec; - private ResponseSpecification responseSpec; - private DatatableHelper datatableHelper; - 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.datatableHelper = new DatatableHelper(this.requestSpec, this.responseSpec); - this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); - this.savingsProductHelper = new SavingsProductHelper(); - this.globalConfigurationHelper = new GlobalConfigurationHelper(); - } + private static final String DATATABLE_NAME_PREFIX = "dt_savings_transaction_"; + private static final String NUMBER_COLUMN = "aNumber"; + private static final String STRING_COLUMN = "aString"; + private static final String BOOLEAN_COLUMN = "aBoolean"; + private static final long COLUMN_LENGTH = 10L; + + /** three declared columns plus the primary key and the two audit columns the server adds */ + private static final int EXPECTED_COLUMN_COUNT = 6; + + private static final String START_DATE = "01 Jun 2023"; + private static final String FIRST_DEPOSIT_DATE = "05 Jun 2023"; + private static final String DEPOSIT_AMOUNT = "100"; + private static final int UPDATED_NUMBER_VALUE = 100; @Test public void testDatatableCreateReadUpdateDeleteForSavingsAccountTransaction() { - // create dataTable - String datatableName = Utils.uniqueRandomStringGenerator("dt_savings_transaction_", 5).toLowerCase().toLowerCase(); - String column1Name = "aNumber"; - String column2Name = "aString"; - String column3Name = "aBoolean"; - - PostDataTablesRequest request = new PostDataTablesRequest(); - request.setDatatableName(datatableName); - request.setApptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME); - request.setMultiRow(false); - - PostColumnHeaderData column1HeaderRequestData = new PostColumnHeaderData(); - column1HeaderRequestData.setName(column1Name); - column1HeaderRequestData.setType("Number"); - column1HeaderRequestData.setMandatory(false); - column1HeaderRequestData.setLength(10L); - column1HeaderRequestData.setCode(""); - column1HeaderRequestData.setUnique(false); - column1HeaderRequestData.setIndexed(false); - - request.addColumnsItem(column1HeaderRequestData); - - PostColumnHeaderData column2HeaderRequestData = new PostColumnHeaderData(); - column2HeaderRequestData.setName(column2Name); - column2HeaderRequestData.setType("String"); - column2HeaderRequestData.setMandatory(false); - column2HeaderRequestData.setLength(10L); - column2HeaderRequestData.setCode(""); - column2HeaderRequestData.setUnique(false); - column2HeaderRequestData.setIndexed(false); - - request.addColumnsItem(column2HeaderRequestData); - - PostDataTablesResponse response = datatableHelper.createDatatable(request); - assertNotNull(response.getResourceIdentifier()); + final String datatableName = uniqueDatatableName(); - // update datatable - PutDataTablesRequest putRequest = new PutDataTablesRequest(); - putRequest.setApptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME); - PutDataTablesRequestAddColumns column3HeaderPutRequestData = new PutDataTablesRequestAddColumns(); - column3HeaderPutRequestData.setName(column3Name); - column3HeaderPutRequestData.setType("Boolean"); - column3HeaderPutRequestData.setMandatory(false); + final PostDataTablesRequest request = new PostDataTablesRequest().datatableName(datatableName) + .apptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME).multiRow(false).addColumnsItem(numberColumn()) + .addColumnsItem(stringColumn()); - putRequest.addAddColumnsItem(column3HeaderPutRequestData); + final PostDataTablesResponse response = datatableHelper.createDatatable(request); + assertNotNull(response.getResourceIdentifier()); - PutDataTablesResponse updateResponse = datatableHelper.updateDatatable(datatableName, putRequest); - assertNotNull(updateResponse.getResourceIdentifier()); + final PutDataTablesRequest putRequest = new PutDataTablesRequest().apptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME) + .addAddColumnsItem(new PutDataTablesRequestAddColumns().name(BOOLEAN_COLUMN).type("Boolean").mandatory(false)); - // verify Datatable got created - GetDataTablesResponse dataTable = datatableHelper.getDataTableDetails(datatableName); + final PutDataTablesResponse updateResponse = datatableHelper.updateDatatable(datatableName, putRequest); + assertNotNull(updateResponse.getResourceIdentifier()); - // verfify columns - List columnHeaderData = dataTable.getColumnHeaderData(); + final GetDataTablesResponse dataTable = datatableHelper.getDatatable(datatableName); + final List columnHeaderData = dataTable.getColumnHeaderData(); assertNotNull(columnHeaderData); + assertEquals(EXPECTED_COLUMN_COUNT, columnHeaderData.size()); - // two columns with 1 primary key and 2 audit columns created - assertEquals(6, columnHeaderData.size()); - - // deleting the datatable - String deletedDataTableName = this.datatableHelper.deleteDatatable(datatableName); - assertEquals(datatableName, deletedDataTableName, "ERROR IN DELETING THE DATATABLE"); + assertEquals(datatableName, datatableHelper.deleteDatatable(datatableName).getResourceIdentifier(), + "ERROR IN DELETING THE DATATABLE"); } @Test public void testDatatableCreateReadUpdateDeleteEntryForSavingsAccountTransaction() { - // Create Client - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - // Create savings product and account - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); - - final Integer transactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, - CommonConstants.RESPONSE_RESOURCE_ID); + final Long clientId = createClient(START_DATE); + assertNotNull(clientId); + final Long savingsId = createSavingsAccountDailyPosting(clientId); + final Long transactionId = deposit(savingsId, DEPOSIT_AMOUNT, FIRST_DEPOSIT_DATE).getResourceId(); assertNotNull(transactionId); - // create dataTable - String datatableName = Utils.uniqueRandomStringGenerator("dt_savings_transaction_", 5).toLowerCase().toLowerCase(); - String column1Name = "aNumber"; + final String datatableName = uniqueDatatableName(); + final PostDataTablesRequest request = new PostDataTablesRequest().datatableName(datatableName) + .apptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME).multiRow(true).addColumnsItem(numberColumn()); - PostDataTablesRequest request = new PostDataTablesRequest(); - request.setDatatableName(datatableName); - request.setApptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME); - request.setMultiRow(true); - - PostColumnHeaderData column1HeaderRequestData = new PostColumnHeaderData(); - column1HeaderRequestData.setName(column1Name); - column1HeaderRequestData.setType("Number"); - column1HeaderRequestData.setMandatory(false); - column1HeaderRequestData.setLength(10L); - column1HeaderRequestData.setCode(""); - column1HeaderRequestData.setUnique(false); - column1HeaderRequestData.setIndexed(false); - - request.addColumnsItem(column1HeaderRequestData); + final PostDataTablesResponse response = datatableHelper.createDatatable(request); + assertNotNull(response); + assertEquals(datatableName, response.getResourceIdentifier()); - PostDataTablesResponse response = datatableHelper.createDatatable(request); + final Long datatableId = datatableHelper.createDatatableEntry(datatableName, transactionId, entry(Utils.randomNumberGenerator(5))) + .getResourceId(); + assertNotNull(datatableId); - assertNotNull(response); + assertEquals(1, datatableHelper.getDatatableEntries(datatableName, transactionId).path("data").size(), + "Expected exactly the one row that was just added"); - String createdName = response.getResourceIdentifier(); - assertEquals(datatableName, createdName); + final PutDataTablesAppTableIdDatatableIdResponse updateResponse = datatableHelper.updateDatatableEntry(datatableName, transactionId, + datatableId, entry(UPDATED_NUMBER_VALUE)); + assertEquals(transactionId, Long.valueOf(updateResponse.getTransactionId())); + assertEquals(datatableId, updateResponse.getResourceId()); - // add entries - final HashMap datatableEntryMap = new HashMap<>(); - datatableEntryMap.put(column1Name, Utils.randomNumberGenerator(5)); - datatableEntryMap.put("locale", "en"); - datatableEntryMap.put("dateFormat", "yyyy-MM-dd"); + final String deletedTransactionId = datatableHelper.deleteDatatableEntries(datatableName, transactionId).getTransactionId(); + assertEquals(transactionId, Long.valueOf(deletedTransactionId), "ERROR IN DELETING THE DATATABLE ENTRIES"); - String datatabelEntryRequestJsonString = new Gson().toJson(datatableEntryMap); + assertEquals(datatableName, datatableHelper.deleteDatatable(datatableName).getResourceIdentifier(), + "ERROR IN DELETING THE DATATABLE"); + } - final boolean genericResultSet = true; + private String uniqueDatatableName() { + return Utils.uniqueRandomStringGenerator(DATATABLE_NAME_PREFIX, 5).toLowerCase(); + } - HashMap datatableEntryResponseFirst = this.datatableHelper.createDatatableEntry(datatableName, transactionId, - genericResultSet, datatabelEntryRequestJsonString); + private PostColumnHeaderData numberColumn() { + return column(NUMBER_COLUMN, "Number"); + } - Integer datatableId = (Integer) datatableEntryResponseFirst.get("resourceId"); - assertNotNull(datatableId); + private PostColumnHeaderData stringColumn() { + return column(STRING_COLUMN, "String"); + } - // Read the Datatable entry generated with genericResultSet - HashMap items = this.datatableHelper.readDatatableEntry(datatableName, transactionId, genericResultSet, null, ""); - assertNotNull(items); - assertEquals(1, ((List) items.get("data")).size()); - - // update datatable entry - datatableEntryMap.put(column1Name, 100); - datatableEntryMap.put("locale", "en"); - datatableEntryMap.put("dateFormat", "yyyy-MM-dd"); - datatabelEntryRequestJsonString = new Gson().toJson(datatableEntryMap); - HashMap updatedDatatableEntryResponse = this.datatableHelper.updateDatatableEntry(datatableName, transactionId, - datatableId, false, datatabelEntryRequestJsonString); - - assertEquals(transactionId, Integer.valueOf((String) updatedDatatableEntryResponse.get("transactionId"))); - assertEquals(datatableId, updatedDatatableEntryResponse.get("resourceId")); - - // deleting datatable entries - String deletedTransactionId = (String) this.datatableHelper.deleteDatatableEntries(datatableName, transactionId, "transactionId"); - assertEquals(transactionId, Integer.valueOf(deletedTransactionId), "ERROR IN DELETING THE DATATABLE ENTRIES"); - - // deleting the datatable - String deletedDataTableName = this.datatableHelper.deleteDatatable(datatableName); - assertEquals(datatableName, deletedDataTableName, "ERROR IN DELETING THE DATATABLE"); + private PostColumnHeaderData column(final String name, final String type) { + return new PostColumnHeaderData().name(name).type(type).mandatory(false).length(COLUMN_LENGTH).code("").unique(false) + .indexed(false); } - private Integer createSavingsAccountDailyPosting(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - 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); - return savingsId; + private Map entry(final Integer numberValue) { + return Map.of(NUMBER_COLUMN, numberValue, "locale", SavingsTestData.LOCALE, "dateFormat", "yyyy-MM-dd"); } - private Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private Long createSavingsAccountDailyPosting(final Long clientId) { + final PostSavingsProductsRequest product = SavingsRequestBuilders.savingsProduct( + SavingsTestData.InterestCompoundingPeriodType.DAILY, SavingsTestData.InterestPostingPeriodType.DAILY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE); + final Long productId = createSavingsProduct(product).getResourceId(); + assertNotNull(productId); + + final Long savingsId = submitSavingsApplication(clientId, productId, START_DATE).getSavingsId(); + assertNotNull(savingsId); + + approveSavings(savingsId, START_DATE); + SavingsTestValidators.verifySavingsIsApproved(savingsHelper.getSavingsStatus(savingsId)); + + activateSavings(savingsId, START_DATE); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId)); + return savingsId; } - // Reset configuration fields @AfterEach public void tearDown() { globalConfigurationHelper.resetAllDefaultGlobalConfigurations(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionTest.java index 319983f7863..8cc92162ac5 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionTest.java @@ -18,389 +18,363 @@ */ package org.apache.fineract.integrationtests; -import static org.apache.fineract.integrationtests.common.CommonConstants.RESPONSE_RESOURCE_ID; -import static org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper.PAYMENT_TYPE_ID; -import static org.apache.fineract.integrationtests.common.system.DatatableHelper.addDatatableColumn; import static org.apache.http.HttpStatus.SC_CONFLICT; import static org.apache.http.HttpStatus.SC_FORBIDDEN; import static org.apache.http.HttpStatus.SC_OK; -import static org.hamcrest.Matchers.anyOf; -import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.google.common.base.Strings; -import com.google.gson.Gson; -import io.restassured.builder.RequestSpecBuilder; -import io.restassured.builder.ResponseSpecBuilder; -import io.restassured.http.ContentType; -import io.restassured.path.json.JsonPath; -import io.restassured.specification.RequestSpecification; -import io.restassured.specification.ResponseSpecification; +import com.fasterxml.jackson.core.JsonProcessingException; import java.time.LocalDate; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import java.util.stream.Collectors; -import org.apache.fineract.batch.domain.BatchRequest; -import org.apache.fineract.batch.domain.BatchResponse; -import org.apache.fineract.batch.domain.Header; -import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; -import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; -import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.client.feign.ObjectMapperFactory; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.BatchRequest; +import org.apache.fineract.client.models.BatchResponse; +import org.apache.fineract.client.models.Header; +import org.apache.fineract.client.models.PostColumnHeaderData; +import org.apache.fineract.client.models.PostDataTablesRequest; +import org.apache.fineract.client.models.PostSavingsAccountTransactionsRequest; +import org.apache.fineract.client.models.PostSavingsAccountTransactionsResponse; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountTransactionData; import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.integrationtests.common.BatchHelper; -import org.apache.fineract.integrationtests.common.BusinessDateHelper; -import org.apache.fineract.integrationtests.common.ClientHelper; -import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; +import org.apache.fineract.infrastructure.dataqueries.data.EntityTables; +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.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.common.savings.SavingsTransactionData; -import org.apache.fineract.integrationtests.common.system.DatatableHelper; +import org.apache.fineract.integrationtests.common.batch.BatchServiceHelper; import org.junit.jupiter.api.AfterEach; -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" }) -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccountTransactionTest { +public class SavingsAccountTransactionTest extends FeignSavingsTestBase { - private static final Logger log = LoggerFactory.getLogger(SavingsAccountTransactionTest.class); + private static final Logger LOG = LoggerFactory.getLogger(SavingsAccountTransactionTest.class); - public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; private static final int CONCURRENT_SAVINGS_ITERATIONS = 10; - private static final int CONCURRENT_SAVINGS_THREAD_COUNT = CONCURRENT_SAVINGS_ITERATIONS; private static final int CONCURRENT_BATCH_ITERATIONS = 5; - private static final int CONCURRENT_BATCH_THREAD_COUNT = CONCURRENT_BATCH_ITERATIONS * 2; private static final int DEADLOCK_BATCH_ITERATIONS = 5; - private static final int DEADLOCK_BATCH_THREAD_COUNT = DEADLOCK_BATCH_ITERATIONS * 2; private static final int EXECUTOR_TIMEOUT_SECONDS = 30; - final String startDateString = "03 June 2023"; - final String depositDateString = "05 June 2023"; - final String withdrawDateString = "10 June 2023"; - - private String authenticationKey; - private ResponseSpecification responseSpec; - private ResponseSpecification concurrentResponseSpec; - private ResponseSpecification deadlockResponseSpec; - private RequestSpecification requestSpec; - private SavingsProductHelper savingsProductHelper; - private SavingsAccountHelper savingsAccountHelper; - private DatatableHelper datatableHelper; - private GlobalConfigurationHelper globalConfigurationHelper; - - @BeforeEach - public void setup() { - Utils.initializeRESTAssured(); - this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); - this.authenticationKey = Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey(); - this.requestSpec.header("Authorization", "Basic " + authenticationKey); - this.responseSpec = new ResponseSpecBuilder().expectStatusCode(SC_OK).build(); - this.concurrentResponseSpec = new ResponseSpecBuilder().expectStatusCode(anyOf(is(SC_OK), is(SC_CONFLICT))).build(); - this.deadlockResponseSpec = new ResponseSpecBuilder().expectStatusCode(anyOf(is(SC_OK), is(SC_CONFLICT), is(SC_FORBIDDEN))).build(); - this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); - this.savingsProductHelper = new SavingsProductHelper(); - this.datatableHelper = new DatatableHelper(this.requestSpec, this.responseSpec); - globalConfigurationHelper = new GlobalConfigurationHelper(); - } + + private static final String START_DATE = "03 June 2023"; + private static final String DEPOSIT_DATE = "05 June 2023"; + private static final String WITHDRAW_DATE = "10 June 2023"; + private static final String DEPOSIT_AMOUNT = "100"; + private static final String WITHDRAWAL_AMOUNT = "50"; + + private static final String CONTENDED_SUBMITTED_DATE = "08 January 2013"; + private static final String CONTENDED_APPROVED_DATE = "09 January 2013"; + private static final String CONTENDED_ACTIVATED_DATE = "01 March 2013"; + private static final String CONTENDED_TRANSACTION_DATE = CONTENDED_ACTIVATED_DATE; + private static final String CONTENDED_TRANSACTION_AMOUNT = "10"; + + private static final String SAVINGS_TRANSACTION_APP_TABLE_NAME = EntityTables.SAVINGS_TRANSACTION.getName(); + private static final String DATATABLE_NAME_PREFIX = "dt_savings_transaction_"; + private static final String STRING_COLUMN_TYPE = "String"; + private static final String FIRST_COLUMN = "string1"; + private static final String SECOND_COLUMN = "string2"; + private static final long FIRST_COLUMN_LENGTH = 10L; + private static final long SECOND_COLUMN_LENGTH = 12L; + private static final String COLUMN_VALUE_PREFIX = "VAL_"; + private static final int COLUMN_VALUE_LENGTH = 3; + private static final int DATATABLE_NAME_SUFFIX_LENGTH = 5; + + private static final long DEPOSIT_REQUEST_ID = 1L; + private static final long WITHDRAW_REQUEST_ID = 2L; + private static final long ADD_ENTRY_REQUEST_ID = 3L; + private static final long DELETE_ENTRY_REQUEST_ID = 4L; + private static final long SECOND_DEPOSIT_REQUEST_ID = 3L; + private static final long SECOND_WITHDRAW_REQUEST_ID = 4L; + private static final int FULL_BATCH_SIZE = 4; + private static final int ROLLED_BACK_BATCH_SIZE = 1; + + private static final String DEPOSIT_COMMAND = "deposit"; + private static final String WITHDRAWAL_COMMAND = "withdrawal"; + private static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + + private static final String DEPOSIT_RESOURCE_ID_REFERENCE = "$.resourceId"; + private static final String CREATED_TRANSACTION_ID_REFERENCE = "$.transactionId"; + private static final String CHILD_ROW_FAILURE = "Cannot add or update a child row"; + + private final BatchServiceHelper batchHelper = new BatchServiceHelper(); @Test public void verifySavingsTransactionSubmittedOnDateAndTransactionDate() { - LocalDate today = Utils.getLocalDateOfTenant(); - try { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(true)); - BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, today); - - LocalDate depositDate = Utils.getDateAsLocalDate(depositDateString); - LocalDate withdrawDate = Utils.getDateAsLocalDate(withdrawDateString); + final LocalDate today = Utils.getLocalDateOfTenant(); - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDateString); - assertNotNull(clientID); + businessDateHelper.runAt(today.toString(), () -> { + final Long clientId = createClient(START_DATE); + assertNotNull(clientId); - final Integer savingsId = createApproveActivateSavingsAccountDailyPosting(clientID, startDateString); + final Long savingsId = createApproveActivateSavings(clientId, dailyPostingProduct(), START_DATE); assertNotNull(savingsId); - performSavingsTransaction(savingsId, "100", depositDate, true); - performSavingsTransaction(savingsId, "50", withdrawDate, false); - } finally { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(false)); - } + verifyTransactionDates(savingsId, deposit(savingsId, DEPOSIT_AMOUNT, DEPOSIT_DATE).getResourceId(), + Utils.getDateAsLocalDate(DEPOSIT_DATE), today, "Deposit"); + verifyTransactionDates(savingsId, withdraw(savingsId, WITHDRAWAL_AMOUNT, WITHDRAW_DATE).getResourceId(), + Utils.getDateAsLocalDate(WITHDRAW_DATE), today, "Withdrawal"); + }); } @Test public void testConcurrentSavingsTransactions() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); - ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); - - final Integer savingsProductId = createSavingsProductDailyPosting(); - assertNotNull(savingsProductId); + final Long savingsId = createContendedSavings(createClient()); - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL); - this.savingsAccountHelper.approveSavings(savingsId); - HashMap savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); + final List attempts = new ArrayList<>(); + for (int i = 0; i < CONCURRENT_SAVINGS_ITERATIONS; i++) { + final String note = noteFor("note", i); + attempts.add(() -> depositThenWithdraw(savingsId, note)); + } - runConcurrentSavingsTransactions(savingsId); + runConcurrently("Concurrent savings transactions", attempts); } @Test public void testConcurrentSavingsBatchTransactions() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec); - ClientHelper.verifyClientCreatedOnServer(this.requestSpec, this.responseSpec, clientID); - - final Integer savingsProductId = createSavingsProductDailyPosting(); - assertNotNull(savingsProductId); - - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL); - this.savingsAccountHelper.approveSavings(savingsId); - HashMap savingsStatusHashMap = this.savingsAccountHelper.activateSavings(savingsId); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); - - // creating datatable for client entity - final HashMap columnMap = new HashMap<>(); - String datatableName = Utils.uniqueRandomStringGenerator("dt_savings_transaction_", 5).toLowerCase(); - columnMap.put("datatableName", datatableName); - columnMap.put("apptableName", "m_savings_account_transaction"); - columnMap.put("multiRow", false); - String string1 = "string1"; - String string2 = "string2"; - List columnNames = List.of(string1, string2); - - final List> datatableColumnsList = new ArrayList<>(); - addDatatableColumn(datatableColumnsList, string1, "String", false, 10, null); - addDatatableColumn(datatableColumnsList, string2, "String", true, 12, null); - columnMap.put("columns", datatableColumnsList); - String datatableJson = new Gson().toJson(columnMap); - this.datatableHelper.createDatatable(datatableJson, ""); + final Long savingsId = createContendedSavings(createClient()); + final String datatableName = createSavingsTransactionDatatable(); try { - runConcurrentSavingsBatchTransactions(savingsId, datatableName, columnNames); + final List attempts = new ArrayList<>(); + for (int i = 0; i < CONCURRENT_BATCH_ITERATIONS; i++) { + final String note = noteFor("note", i); + attempts.add(() -> runSavingsAndDatatableBatch(savingsId, note, datatableName, true)); + attempts.add(() -> runSavingsAndDatatableBatch(savingsId, note, datatableName, false)); + } + + runConcurrently("Concurrent savings batch transactions", attempts); } finally { - this.datatableHelper.deleteDatatable(datatableName); + datatableHelper.deleteDatatable(datatableName); } } @Test public void testDeadlockSavingsBatchTransactions() { - final Integer clientID = ClientHelper.createClient(requestSpec, responseSpec); - ClientHelper.verifyClientCreatedOnServer(requestSpec, responseSpec, clientID); + final Long clientId = createClient(); + final Long firstSavingsId = createContendedSavings(clientId); + final Long secondSavingsId = createContendedSavings(clientId); - final Integer savingsProductId = createSavingsProductDailyPosting(); - assertNotNull(savingsProductId); - - final Integer savingsId1 = savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL); - savingsAccountHelper.approveSavings(savingsId1); - savingsAccountHelper.activateSavings(savingsId1); - - final Integer savingsId2 = savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductId, ACCOUNT_TYPE_INDIVIDUAL); - savingsAccountHelper.approveSavings(savingsId2); - savingsAccountHelper.activateSavings(savingsId2); + // Half the threads take the accounts in the opposite order, which is what makes them deadlock. + final List attempts = new ArrayList<>(); + for (int i = 0; i < DEADLOCK_BATCH_ITERATIONS; i++) { + final String firstNote = noteFor("note1", i); + final String secondNote = noteFor("note2", i); + attempts.add(() -> runTwoAccountBatch(firstSavingsId, secondSavingsId, firstNote)); + attempts.add(() -> runTwoAccountBatch(secondSavingsId, firstSavingsId, secondNote)); + } - runConcurrentDeadlockSavingsBatchTransactions(savingsId1, savingsId2); + runConcurrently("Concurrent deadlock savings batch transactions", attempts); } - private void performSavingsTransaction(Integer savingsId, String amount, LocalDate transactionDate, boolean isDeposit) { - String transactionType = isDeposit ? "Deposit" : "Withdrawal"; - String formattedTransactionDate = Utils.dateFormatter.format(transactionDate); - Integer transactionId = isDeposit - ? (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, amount, formattedTransactionDate, - RESPONSE_RESOURCE_ID) - : (Integer) this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, amount, formattedTransactionDate, - RESPONSE_RESOURCE_ID); - + private void verifyTransactionDates(final Long savingsId, final Long transactionId, final LocalDate expectedTransactionDate, + final LocalDate expectedSubmittedOnDate, final String transactionType) { assertNotNull(transactionId); - HashMap transaction = savingsAccountHelper.getSavingsTransaction(savingsId, transactionId); + final SavingsAccountTransactionData transaction = savingsTransactionHelper.getTransaction(savingsId, transactionId); assertNotNull(transaction); - assertEquals(transactionId, (Integer) transaction.get("id"), "Check Savings " + transactionType + " Transaction"); - LocalDate transactionDateFromResponse = extractLocalDate(transaction, "date"); - assertTrue(DateUtils.isEqual(transactionDate, transactionDateFromResponse), "Transaction Date check for Savings " + transactionType - + " Transaction. Expected: " + transactionDate + ", current: " + transactionDateFromResponse); - LocalDate submittedOnDate = Utils.getLocalDateOfTenant(); - LocalDate submittedOnDateFromResponse = extractLocalDate(transaction, "submittedOnDate"); - assertTrue(DateUtils.isEqual(submittedOnDate, submittedOnDateFromResponse), "Submitted On Date check for Savings " + transactionType - + " Transaction. Expected: " + submittedOnDate + ", current: " + submittedOnDateFromResponse); + assertEquals(transactionId, transaction.getId(), "Check Savings " + transactionType + " Transaction"); + assertTrue(DateUtils.isEqual(expectedTransactionDate, transaction.getDate()), "Transaction Date check for Savings " + + transactionType + " Transaction. Expected: " + expectedTransactionDate + ", current: " + transaction.getDate()); + assertTrue(DateUtils.isEqual(expectedSubmittedOnDate, transaction.getSubmittedOnDate()), + "Submitted On Date check for Savings " + transactionType + " Transaction. Expected: " + expectedSubmittedOnDate + + ", current: " + transaction.getSubmittedOnDate()); } - private LocalDate extractLocalDate(HashMap transactionMap, String fieldName) { - List dateStringList = (List) transactionMap.get(fieldName); - LocalDate extractedDate = dateStringList.stream() - .collect(Collectors.collectingAndThen(Collectors.toList(), list -> LocalDate.of(list.get(0), list.get(1), list.get(2)))); - return extractedDate; + private void depositThenWithdraw(final Long savingsId, final String note) { + if (accepted(() -> savingsTransactionHelper.deposit(savingsId, contendedTransaction(note)))) { + accepted(() -> savingsTransactionHelper.withdraw(savingsId, contendedTransaction(note))); + } } - private Integer createApproveActivateSavingsAccountDailyPosting(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - assertNotNull(savingsProductID); - return savingsAccountHelper.createApproveActivateSavingsAccount(clientID, savingsProductID, startDate); + private static boolean accepted(final Supplier transaction) { + try { + assertNotNull(transaction.get().getResourceId(), "An accepted transaction should carry a resource id"); + return true; + } catch (CallFailedRuntimeException e) { + assertEquals(SC_CONFLICT, e.getStatus(), "A losing thread should be told it conflicted, but got: " + e.getMessage()); + return false; + } } - private Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); - } + private void runSavingsAndDatatableBatch(final Long savingsId, final String note, final String datatableName, + final boolean enclosingTransaction) { + final List requests = List.of(savingsTransactionRequest(DEPOSIT_REQUEST_ID, savingsId, note, DEPOSIT_COMMAND), + addDatatableEntryRequest(datatableName), deleteDatatableEntryRequest(datatableName), + savingsTransactionRequest(WITHDRAW_REQUEST_ID, savingsId, note, WITHDRAWAL_COMMAND)); - private void runConcurrentSavingsTransactions(Integer savingsId) { - ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_SAVINGS_THREAD_COUNT); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(CONCURRENT_SAVINGS_THREAD_COUNT); - List failures = Collections.synchronizedList(new ArrayList<>()); + final List responses = batchHelper.handleBatch(requests, enclosingTransaction); + assertNotNull(responses, "Responses"); - for (int i = 0; i < CONCURRENT_SAVINGS_ITERATIONS; i++) { - SavingsTransactionData transactionData = SavingsTransactionData.builder().transactionDate(SavingsAccountHelper.TRANSACTION_DATE) - .transactionAmount("10").paymentTypeId(PAYMENT_TYPE_ID).note("note_" + i).build(); - executor.execute(() -> { - try { - startLatch.await(); - new TransactionExecutor(new SavingsAccountHelper(newConcurrentRequestSpecification(), concurrentResponseSpec), - savingsId, transactionData).run(); - } catch (AssertionError | Exception e) { - String failure = "concurrent savings transaction failed for note " + transactionData.getNote() + ": " + e.getMessage(); - failures.add(failure); - log.error(failure, e); - } finally { - doneLatch.countDown(); - } - }); + if (enclosingTransaction) { + assertEnclosingTransactionBatchResponses(responses); + } else { + assertNonEnclosingTransactionBatchResponses(responses); } - - awaitExecutorCompletion(executor, startLatch, doneLatch, "Concurrent savings transactions", failures); } - private void runConcurrentSavingsBatchTransactions(Integer savingsId, String datatableName, List columnNames) { - ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_BATCH_THREAD_COUNT); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(CONCURRENT_BATCH_THREAD_COUNT); - List failures = Collections.synchronizedList(new ArrayList<>()); - - for (int i = 0; i < CONCURRENT_BATCH_ITERATIONS; i++) { - SavingsTransactionData transactionData = SavingsTransactionData.builder().transactionDate(SavingsAccountHelper.TRANSACTION_DATE) - .transactionAmount("10").paymentTypeId(PAYMENT_TYPE_ID).note("note_" + i).build(); - submitConcurrentSavingsBatch(executor, startLatch, doneLatch, failures, savingsId, transactionData, true, datatableName, - columnNames); - submitConcurrentSavingsBatch(executor, startLatch, doneLatch, failures, savingsId, transactionData, false, datatableName, - columnNames); - } + private void runTwoAccountBatch(final Long firstSavingsId, final Long secondSavingsId, final String note) { + final List requests = List.of(savingsTransactionRequest(DEPOSIT_REQUEST_ID, firstSavingsId, note, DEPOSIT_COMMAND), + savingsTransactionRequest(WITHDRAW_REQUEST_ID, firstSavingsId, note, WITHDRAWAL_COMMAND), + savingsTransactionRequest(SECOND_DEPOSIT_REQUEST_ID, secondSavingsId, note, DEPOSIT_COMMAND), + savingsTransactionRequest(SECOND_WITHDRAW_REQUEST_ID, secondSavingsId, note, WITHDRAWAL_COMMAND)); + + final List responses = batchHelper.handleBatch(requests, true); + assertNotNull(responses, "Responses"); + + final BatchResponse first = responses.get(0); + final Integer statusCode = first.getStatusCode(); + final String body = first.getBody() == null ? "" : first.getBody(); + assertNotNull(statusCode, "First response status code"); + assertTrue(SC_OK == statusCode || SC_CONFLICT == statusCode || (SC_FORBIDDEN == statusCode && body.contains(CHILD_ROW_FAILURE)), + "Status code: " + statusCode + ", message: " + body); - awaitExecutorCompletion(executor, startLatch, doneLatch, "Concurrent savings batch transactions", failures); + assertRolledBackOrComplete(responses, statusCode); } - private void submitConcurrentSavingsBatch(ExecutorService executor, CountDownLatch startLatch, CountDownLatch doneLatch, - List failures, Integer savingsId, SavingsTransactionData transactionData, boolean enclosingTransaction, - String datatableName, List columnNames) { - executor.execute(() -> { - try { - startLatch.await(); - executeSavingsBatchTransaction(savingsId, transactionData, enclosingTransaction, datatableName, columnNames); - } catch (AssertionError | Exception e) { - String batchType = enclosingTransaction ? "enclosing-transaction" : "non-enclosing-transaction"; - String failure = batchType + " batch failed for note " + transactionData.getNote() + ": " + e.getMessage(); - failures.add(failure); - log.error(failure, e); - } finally { - doneLatch.countDown(); - } - }); + private static void assertEnclosingTransactionBatchResponses(final List responses) { + final Integer statusCode = responses.get(0).getStatusCode(); + assertNotNull(statusCode, "First enclosingTransaction response status code"); + assertTrue(SC_OK == statusCode || SC_CONFLICT == statusCode, "Status code: " + statusCode); + + assertRolledBackOrComplete(responses, statusCode); } - private void executeSavingsBatchTransaction(Integer savingsId, SavingsTransactionData transactionData, boolean enclosingTransaction, - String datatableName, List columnNames) { - final BatchRequest depositRequest = BatchHelper.depositSavingAccount(1L, savingsId.longValue(), transactionData); - Set
headers = Optional.ofNullable(depositRequest.getHeaders()).orElse(new HashSet<>(1)); - headers.add(new Header("Idempotency-Key", UUID.randomUUID().toString())); - depositRequest.setHeaders(headers); - - BatchRequest addEntryRequest = BatchHelper.createDatatableEntryRequest("$.resourceId", datatableName, columnNames); - addEntryRequest.setReference(1L); - BatchRequest deleteEntryRequest = BatchHelper.deleteDatatableEntryRequest("$.transactionId", datatableName, null); - - final BatchRequest withdrawRequest = BatchHelper.withdrawSavingAccount(2L, savingsId.longValue(), transactionData); - headers = Optional.ofNullable(withdrawRequest.getHeaders()).orElse(new HashSet<>(1)); - headers.add(new Header("Idempotency-Key", UUID.randomUUID().toString())); - withdrawRequest.setHeaders(headers); - - String json = BatchHelper.toJsonString(Arrays.asList(depositRequest, addEntryRequest, deleteEntryRequest, withdrawRequest)); - SavingsAccountHelper savingsHelper = new SavingsAccountHelper(newConcurrentRequestSpecification(), - enclosingTransaction ? concurrentResponseSpec : deadlockResponseSpec); - final List responses = enclosingTransaction - ? BatchHelper.postBatchRequestsWithEnclosingTransaction(savingsHelper.getRequestSpec(), savingsHelper.getResponseSpec(), - json) - : BatchHelper.postBatchRequestsWithoutEnclosingTransaction(savingsHelper.getRequestSpec(), savingsHelper.getResponseSpec(), - json); + private static void assertNonEnclosingTransactionBatchResponses(final List responses) { + assertEquals(FULL_BATCH_SIZE, responses.size(), "Response size for non-enclosingTransaction response"); - assertNotNull(responses, "Responses"); - if (enclosingTransaction) { - assertEnclosingTransactionBatchResponses(responses); + final Integer firstStatusCode = responses.get(0).getStatusCode(); + assertNotNull(firstStatusCode, "First non-enclosingTransaction response status code"); + assertTrue(SC_OK == firstStatusCode || SC_CONFLICT == firstStatusCode, + "First non-enclosingTransaction response status code: " + firstStatusCode); + + final Integer lastStatusCode = responses.get(FULL_BATCH_SIZE - 1).getStatusCode(); + assertNotNull(lastStatusCode, "Last non-enclosingTransaction response status code"); + // Without an enclosing transaction the later steps still run after a failed deposit, but can only fail too. + assertTrue( + SC_OK == firstStatusCode ? (SC_OK == lastStatusCode || SC_CONFLICT == lastStatusCode) + : (SC_FORBIDDEN == lastStatusCode || SC_CONFLICT == lastStatusCode), + "Last non-enclosingTransaction response status code: " + lastStatusCode); + } + + private static void assertRolledBackOrComplete(final List responses, final Integer firstStatusCode) { + if (SC_OK == firstStatusCode) { + assertEquals(FULL_BATCH_SIZE, responses.size(), "Response size for OK response"); + final Integer lastStatusCode = responses.get(FULL_BATCH_SIZE - 1).getStatusCode(); + assertNotNull(lastStatusCode, "Last OK response status code"); + assertEquals(SC_OK, lastStatusCode, "Last OK response status code"); } else { - assertNonEnclosingTransactionBatchResponses(responses); + assertEquals(ROLLED_BACK_BATCH_SIZE, responses.size(), "Response size for failed response"); } } - private RequestSpecification newConcurrentRequestSpecification() { - return new RequestSpecBuilder().setContentType(ContentType.JSON).addHeader("Authorization", "Basic " + authenticationKey).build(); + private static BatchRequest savingsTransactionRequest(final long requestId, final Long savingsId, final String note, + final String command) { + return new BatchRequest().requestId(requestId).relativeUrl("v1/savingsaccounts/" + savingsId + "/transactions?command=" + command) + .method("POST").body(asJson(contendedTransaction(note))) + // Without an idempotency key the server refuses a transaction it has already seen. + .addHeadersItem(new Header().name(IDEMPOTENCY_KEY_HEADER).value(UUID.randomUUID().toString())); } - private void runConcurrentDeadlockSavingsBatchTransactions(Integer savingsId1, Integer savingsId2) { - ExecutorService executor = Executors.newFixedThreadPool(DEADLOCK_BATCH_THREAD_COUNT); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(DEADLOCK_BATCH_THREAD_COUNT); - List failures = Collections.synchronizedList(new ArrayList<>()); + private static BatchRequest addDatatableEntryRequest(final String datatableName) { + final Map entry = List.of(FIRST_COLUMN, SECOND_COLUMN).stream().collect( + Collectors.toMap(column -> column, column -> Utils.randomStringGenerator(COLUMN_VALUE_PREFIX, COLUMN_VALUE_LENGTH))); + return new BatchRequest().requestId(ADD_ENTRY_REQUEST_ID).reference(DEPOSIT_REQUEST_ID) + .relativeUrl("v1/datatables/" + datatableName + "/" + DEPOSIT_RESOURCE_ID_REFERENCE).method("POST").body(asJson(entry)); + } - for (int i = 0; i < DEADLOCK_BATCH_ITERATIONS; i++) { - SavingsTransactionData transactionData1 = SavingsTransactionData.builder() - .transactionDate(SavingsAccountHelper.TRANSACTION_DATE).transactionAmount("10").paymentTypeId(PAYMENT_TYPE_ID) - .note("note1_" + i).build(); - submitDeadlockBatch(executor, startLatch, doneLatch, failures, savingsId1, savingsId2, transactionData1); - - SavingsTransactionData transactionData2 = SavingsTransactionData.builder() - .transactionDate(SavingsAccountHelper.TRANSACTION_DATE).transactionAmount("10").paymentTypeId(PAYMENT_TYPE_ID) - .note("note2_" + i).build(); - submitDeadlockBatch(executor, startLatch, doneLatch, failures, savingsId2, savingsId1, transactionData2); - } + private static BatchRequest deleteDatatableEntryRequest(final String datatableName) { + return new BatchRequest().requestId(DELETE_ENTRY_REQUEST_ID).reference(ADD_ENTRY_REQUEST_ID) + .relativeUrl("v1/datatables/" + datatableName + "/" + CREATED_TRANSACTION_ID_REFERENCE).method("DELETE"); + } - awaitExecutorCompletion(executor, startLatch, doneLatch, "Concurrent deadlock savings batch transactions", failures); + private String createSavingsTransactionDatatable() { + final String datatableName = Utils.uniqueRandomStringGenerator(DATATABLE_NAME_PREFIX, DATATABLE_NAME_SUFFIX_LENGTH).toLowerCase(); + datatableHelper + .createDatatable(new PostDataTablesRequest().datatableName(datatableName).apptableName(SAVINGS_TRANSACTION_APP_TABLE_NAME) + .multiRow(false).addColumnsItem(stringColumn(FIRST_COLUMN, false, FIRST_COLUMN_LENGTH)) + .addColumnsItem(stringColumn(SECOND_COLUMN, true, SECOND_COLUMN_LENGTH))); + return datatableName; } - private void submitDeadlockBatch(ExecutorService executor, CountDownLatch startLatch, CountDownLatch doneLatch, List failures, - Integer savingsId1, Integer savingsId2, SavingsTransactionData transactionData) { - executor.execute(() -> { - try { - startLatch.await(); - runDeadlockBatch(new SavingsAccountHelper(newConcurrentRequestSpecification(), deadlockResponseSpec), savingsId1, - savingsId2, transactionData); - } catch (AssertionError | Exception e) { - String failure = "deadlock batch failed for note " + transactionData.getNote() + ": " + e.getMessage(); - failures.add(failure); - log.error(failure, e); - } finally { - doneLatch.countDown(); - } - }); + private static PostColumnHeaderData stringColumn(final String name, final boolean mandatory, final long length) { + return new PostColumnHeaderData().name(name).type(STRING_COLUMN_TYPE).mandatory(mandatory).length(length); + } + + private static PostSavingsAccountTransactionsRequest contendedTransaction(final String note) { + return SavingsRequestBuilders.deposit(CONTENDED_TRANSACTION_AMOUNT, CONTENDED_TRANSACTION_DATE).note(note); + } + + private Long createContendedSavings(final Long clientId) { + final Long savingsId = submitSavingsApplication(clientId, dailyPostingProduct(), CONTENDED_SUBMITTED_DATE).getSavingsId(); + assertNotNull(savingsId); + + approveSavings(savingsId, CONTENDED_APPROVED_DATE); + activateSavings(savingsId, CONTENDED_ACTIVATED_DATE); + return savingsId; } - private void awaitExecutorCompletion(ExecutorService executor, CountDownLatch startLatch, CountDownLatch doneLatch, String description, - List failures) { + private Long dailyPostingProduct() { + final PostSavingsProductsRequest product = SavingsRequestBuilders.savingsProduct( + SavingsTestData.InterestCompoundingPeriodType.DAILY, SavingsTestData.InterestPostingPeriodType.DAILY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE); + final Long productId = createSavingsProduct(product).getResourceId(); + assertNotNull(productId); + return productId; + } + + private static String noteFor(final String prefix, final int iteration) { + return prefix + "_" + iteration; + } + + private static String asJson(final Object body) { + try { + return ObjectMapperFactory.getShared().writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialise the batch request body " + body, e); + } + } + + private static void runConcurrently(final String description, final List attempts) { + final ExecutorService executor = Executors.newFixedThreadPool(attempts.size()); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(attempts.size()); + final List failures = Collections.synchronizedList(new ArrayList<>()); + + for (final Runnable attempt : attempts) { + executor.execute(() -> { + try { + startLatch.await(); + attempt.run(); + } catch (AssertionError | Exception e) { + final String failure = description + " failed: " + e.getMessage(); + failures.add(failure); + LOG.error(failure, e); + } finally { + doneLatch.countDown(); + } + }); + } + + awaitCompletion(executor, startLatch, doneLatch, description, failures); + } + + private static void awaitCompletion(final ExecutorService executor, final CountDownLatch startLatch, final CountDownLatch doneLatch, + final String description, final List failures) { startLatch.countDown(); try { assertTrue(doneLatch.await(EXECUTOR_TIMEOUT_SECONDS, TimeUnit.SECONDS), description + " did not complete within timeout"); @@ -420,103 +394,6 @@ private void awaitExecutorCompletion(ExecutorService executor, CountDownLatch st assertTrue(failures.isEmpty(), String.join(System.lineSeparator(), failures)); } - private void assertEnclosingTransactionBatchResponses(List responses) { - Integer statusCode1 = responses.get(0).getStatusCode(); - assertNotNull(statusCode1, "First enclosingTransaction response status code"); - assertTrue(SC_OK == statusCode1 || SC_CONFLICT == statusCode1, "Status code: " + statusCode1); - if (SC_OK == statusCode1) { - assertEquals(4, responses.size(), "Response size for enclosingTransaction OK response"); - Integer statusCode4 = responses.get(3).getStatusCode(); - assertNotNull(statusCode4, "Last enclosingTransaction OK response status code"); - assertEquals(SC_OK, statusCode4, "Last enclosingTransaction OK response status code"); - } else { - assertEquals(1, responses.size(), "Response size for enclosingTransaction failed response"); - } - } - - private void assertNonEnclosingTransactionBatchResponses(List responses) { - assertEquals(4, responses.size(), "Response size for non-enclosingTransaction response"); - Integer statusCode1 = responses.get(0).getStatusCode(); - assertNotNull(statusCode1, "First non-enclosingTransaction response status code"); - assertTrue(SC_OK == statusCode1 || SC_CONFLICT == statusCode1, - "First non-enclosingTransaction response status code: " + statusCode1); - - Integer statusCode4 = responses.get(3).getStatusCode(); - assertNotNull(statusCode4, "Last non-enclosingTransaction response status code"); - assertTrue( - SC_OK == statusCode1 ? (SC_OK == statusCode4 || SC_CONFLICT == statusCode4) - : (SC_FORBIDDEN == statusCode4 || SC_CONFLICT == statusCode4), - "Last non-enclosingTransaction response status code: " + statusCode4); - } - - public static class TransactionExecutor implements Runnable { - - private final SavingsAccountHelper savingsHelper; - private final Integer savingsId; - SavingsTransactionData transactionData; - - TransactionExecutor(SavingsAccountHelper savingsAccountHelper, Integer savingsId, SavingsTransactionData transactionData) { - this.savingsId = savingsId; - this.savingsHelper = savingsAccountHelper; - this.transactionData = transactionData; - } - - @Override - public void run() { - log.info("Details of passed concurrent transaction, details (date, amount, note, savingsId) are {},{},{},{}", - transactionData.getTransactionDate(), transactionData.getTransactionAmount(), transactionData.getNote(), savingsId); - String json = transactionData.getJson(); - String response = (String) this.savingsHelper.depositToSavingsAccount(savingsId, json, null); - boolean success = checkConcurrentResponse(response); - if (success) { - response = (String) this.savingsHelper.withdrawalFromSavingsAccount(savingsId, json, null); - checkConcurrentResponse(response); - } - } - - private static boolean checkConcurrentResponse(String response) { - assertNotNull(response, "Single response"); - JsonPath res = JsonPath.from(response); - String statusCode = res.get("httpStatusCode"); - if (statusCode == null) { - assertNotNull(res.get(RESPONSE_RESOURCE_ID), "Single response " + RESPONSE_RESOURCE_ID); - return true; - } - assertEquals(String.valueOf(SC_CONFLICT), statusCode, "Single response status code"); - return false; - } - } - - private void runDeadlockBatch(SavingsAccountHelper savingsHelper, Integer savingsId1, Integer savingsId2, - SavingsTransactionData transactionData) { - final BatchRequest depositRequest1 = BatchHelper.depositSavingAccount(1L, savingsId1.longValue(), transactionData); - final BatchRequest withdrawRequest1 = BatchHelper.withdrawSavingAccount(2L, savingsId1.longValue(), transactionData); - final BatchRequest depositRequest2 = BatchHelper.depositSavingAccount(3L, savingsId2.longValue(), transactionData); - final BatchRequest withdrawRequest2 = BatchHelper.withdrawSavingAccount(4L, savingsId2.longValue(), transactionData); - String json = BatchHelper.toJsonString(Arrays.asList(depositRequest1, withdrawRequest1, depositRequest2, withdrawRequest2)); - RequestSpecification requestSpec = savingsHelper.getRequestSpec(); - ResponseSpecification responseSpec = savingsHelper.getResponseSpec(); - final List responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, json); - assertNotNull(responses, "Responses"); - BatchResponse response1 = responses.get(0); - Integer statusCode = response1.getStatusCode(); - String msg = Strings.nullToEmpty(response1.getBody()); - assertNotNull(statusCode, "First response status code"); - assertTrue( - SC_OK == statusCode || SC_CONFLICT == statusCode - || (SC_FORBIDDEN == statusCode && msg.contains("Cannot add or update a child row")), - "Status code: " + statusCode + ", message: " + msg); - if (SC_OK == statusCode) { - assertEquals(4, responses.size(), "Response size for OK response"); - Integer statusCode4 = responses.get(3).getStatusCode(); - assertNotNull(statusCode4, "Last OK response status code"); - assertEquals(SC_OK, statusCode4, "Last OK response status code"); - } else { - assertEquals(1, responses.size(), "Response size for failed response"); - } - } - - // Reset configuration fields @AfterEach public void tearDown() { globalConfigurationHelper.resetAllDefaultGlobalConfigurations(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionsSearchIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionsSearchIntegrationTest.java index 1b68286d3c3..295cbe7cc4e 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionsSearchIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountTransactionsSearchIntegrationTest.java @@ -20,14 +20,9 @@ import static org.apache.fineract.infrastructure.core.service.DateUtils.parseLocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.core.JsonProcessingException; -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.math.BigDecimal; import java.time.LocalDate; import java.util.HashMap; @@ -36,130 +31,94 @@ import java.util.Map; import java.util.stream.Collectors; import org.apache.fineract.client.models.GetSavingsAccountTransactionsPageItem; +import org.apache.fineract.client.models.PostSavingsProductsResponse; import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; import org.apache.fineract.client.models.SavingsAccountTransactionsSearchResponse; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.integrationtests.common.BusinessDateHelper; -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.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.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.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.search.data.TransactionSearchRequest; 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.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; -@SuppressWarnings({ "rawtypes" }) -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccountTransactionsSearchIntegrationTest { - - public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; - public static final String DEFAULT_DATE_FORMAT = "dd MMMM yyyy"; - public static final Locale DEFAULT_LOCALE = Locale.ENGLISH; - final String startDate = "01 May 2023"; - final String firstDepositDate = "05 May 2023"; - final String secondDepositDate = "09 May 2023"; - final String thirdDepositDate = "12 May 2023"; - final String fourthDepositDate = "01 Jun 2023"; - final String withdrawDate = "10 May 2023"; - - private ResponseSpecification responseSpec; - private ResponseSpecification responseSpecForValidationError; - private RequestSpecification requestSpec; - private SavingsProductHelper savingsProductHelper; - private SavingsAccountHelper savingsAccountHelper; - private SavingsAccountHelper savingsAccountHelperValidationError; - 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.responseSpecForValidationError = new ResponseSpecBuilder().expectStatusCode(400).build(); - this.savingsAccountHelperValidationError = new SavingsAccountHelper(this.requestSpec, this.responseSpecForValidationError); - this.savingsProductHelper = new SavingsProductHelper(); - this.globalConfigurationHelper = new GlobalConfigurationHelper(); - } +public class SavingsAccountTransactionsSearchIntegrationTest extends FeignSavingsTestBase { + + private static final String DEFAULT_DATE_FORMAT = SavingsTestData.DATETIME_PATTERN; + private static final Locale DEFAULT_LOCALE = Locale.ENGLISH; + private static final int BAD_REQUEST = 400; + private static final int NOT_FOUND = 404; + + private final String startDate = "01 May 2023"; + private final String firstDepositDate = "05 May 2023"; + private final String secondDepositDate = "09 May 2023"; + private final String thirdDepositDate = "12 May 2023"; + private final String fourthDepositDate = "01 Jun 2023"; + private final String withdrawDate = "10 May 2023"; @Test - public void testSavingsTransactionsSearchAmountFrom() throws JsonProcessingException { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + public void testSavingsTransactionsSearchAmountFrom() { + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", startDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", startDate); + deposit(savingsId, "300", startDate); TransactionSearchRequest searchParameters = new TransactionSearchRequest().fromAmount(BigDecimal.valueOf(100)); Map queryParams = buildTransactionsSearchQuery(searchParameters, null, null); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(2, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(2, pageItemsList.size()); assertTrue(MathUtil.isEqualTo(BigDecimal.valueOf(100), pageItemsList.get(1).getAmount())); } @Test - public void testSavingsTransactionsSearchAmountFromTo() throws JsonProcessingException { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + public void testSavingsTransactionsSearchAmountFromTo() { + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", startDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", startDate); + deposit(savingsId, "300", startDate); TransactionSearchRequest searchParameters = new TransactionSearchRequest().fromAmount(BigDecimal.valueOf(100)) .toAmount(BigDecimal.valueOf(200)); Map queryParams = buildTransactionsSearchQuery(searchParameters, null, null); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(1, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(1, pageItemsList.size()); assertTrue(MathUtil.isEqualTo(BigDecimal.valueOf(100), pageItemsList.get(0).getAmount())); } @Test - public void testSavingsTransactionsSearchDateFromTo() throws JsonProcessingException { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + public void testSavingsTransactionsSearchDateFromTo() { + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "100", withdrawDate); TransactionSearchRequest searchParameters = new TransactionSearchRequest() .fromDate(firstDepositDate, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE).toDate(withdrawDate, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); Map queryParams = buildTransactionsSearchQuery(searchParameters, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(3, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(3, pageItemsList.size()); assertEquals(parseLocalDate(withdrawDate, DEFAULT_DATE_FORMAT), pageItemsList.get(0).getDate()); @@ -167,35 +126,33 @@ public void testSavingsTransactionsSearchDateFromTo() throws JsonProcessingExcep } @Test - public void testSavingsTransactionsSearchSubmittedDateFromTo() throws JsonProcessingException { + public void testSavingsTransactionsSearchSubmittedDateFromTo() { LocalDate businessDate = Utils.getLocalDateOfTenant(); - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + Long savingsId = createClientWithSavingsAccount(); try { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, new PutGlobalConfigurationsRequest().enabled(true)); - BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, businessDate); + businessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE.name(), businessDate.toString()); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "100", withdrawDate); } finally { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, new PutGlobalConfigurationsRequest().enabled(false)); } + String submittedDate = DateUtils.format(businessDate, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); TransactionSearchRequest searchParameters = new TransactionSearchRequest() .fromSubmittedDate(submittedDate, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE) .toSubmittedDate(submittedDate, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); Map queryParams = buildTransactionsSearchQuery(searchParameters, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(3, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(3, pageItemsList.size()); assertEquals(businessDate, pageItemsList.get(0).getSubmittedOnDate()); @@ -205,23 +162,20 @@ public void testSavingsTransactionsSearchSubmittedDateFromTo() throws JsonProces @Test public void testSavingsTransactionsSearchTransactionTypeDepositAndDefaultSort() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "100", withdrawDate); int typeD = SavingsAccountTransactionType.DEPOSIT.getId(); TransactionSearchRequest searchParameters = new TransactionSearchRequest().types(String.valueOf(typeD)); Map queryParams = buildTransactionsSearchQuery(searchParameters, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(2, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(2, pageItemsList.size()); GetSavingsAccountTransactionsPageItem first = pageItemsList.get(0); @@ -235,25 +189,22 @@ public void testSavingsTransactionsSearchTransactionTypeDepositAndDefaultSort() } @Test - public void testSavingsTransactionsSearchTransactionTypesWithdrawAndDeposit() throws JsonProcessingException { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + public void testSavingsTransactionsSearchTransactionTypesWithdrawAndDeposit() { + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "100", withdrawDate); int typeD = SavingsAccountTransactionType.DEPOSIT.getId(); int typeW = SavingsAccountTransactionType.WITHDRAWAL.getId(); TransactionSearchRequest searchParameters = new TransactionSearchRequest().types(String.valueOf(typeD) + ',' + typeW); Map queryParams = buildTransactionsSearchQuery(searchParameters, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(3, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(3, pageItemsList.size()); assertEquals(Long.valueOf(typeW), pageItemsList.get(0).getTransactionType().getId()); @@ -266,45 +217,39 @@ public void testSavingsTransactionsSearchTransactionTypesWithdrawAndDeposit() th @Test public void testSavingsTransactionsSearchPaginationAndNoFilter() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "100", withdrawDate); TransactionSearchRequest searchParameters = new TransactionSearchRequest().pageable(0, 2, null, null); Map queryParams = buildTransactionsSearchQuery(searchParameters, null, null); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(3, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); assertEquals(2, transactionsResponse.getContent().size()); } @Test public void testSavingsTransactionsSearchTransactionTypeDepositAndSortByAmountAsc() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "200", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "200", withdrawDate); int typeD = SavingsAccountTransactionType.DEPOSIT.getId(); TransactionSearchRequest searchParameters = new TransactionSearchRequest().types(String.valueOf(typeD)).pageable(null, null, "amount", Sort.Direction.ASC); Map queryParams = buildTransactionsSearchQuery(searchParameters, DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(2, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(2, pageItemsList.size()); GetSavingsAccountTransactionsPageItem first = pageItemsList.get(0); @@ -319,16 +264,14 @@ public void testSavingsTransactionsSearchTransactionTypeDepositAndSortByAmountAs @Test public void testSavingsTransactionsSearchWithFiltersSortingAndPagination() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "50", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "400", thirdDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "200", fourthDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.postInterestForSavings(savingsId); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "50", withdrawDate); + deposit(savingsId, "400", thirdDepositDate); + deposit(savingsId, "200", fourthDepositDate); + savingsHelper.postInterest(savingsId); int typeD = SavingsAccountTransactionType.DEPOSIT.getId(); TransactionSearchRequest searchParameters = new TransactionSearchRequest().fromAmount(BigDecimal.valueOf(100)) @@ -336,12 +279,11 @@ public void testSavingsTransactionsSearchWithFiltersSortingAndPagination() { .toDate("2023-06-01", DateUtils.DEFAULT_DATE_FORMAT, DEFAULT_LOCALE).types(String.valueOf(typeD)) .pageable(0, 2, "amount", Sort.Direction.DESC); Map queryParams = buildTransactionsSearchQuery(searchParameters, DateUtils.DEFAULT_DATE_FORMAT, DEFAULT_LOCALE); - SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchSavingsTransactions(savingsId, - queryParams); + SavingsAccountTransactionsSearchResponse transactionsResponse = savingsTransactionHelper.searchTransactions(savingsId, queryParams); - Assertions.assertNotNull(transactionsResponse); + assertNotNull(transactionsResponse); assertEquals(3, transactionsResponse.getTotal()); - Assertions.assertNotNull(transactionsResponse.getContent()); + assertNotNull(transactionsResponse.getContent()); List pageItemsList = List.copyOf(transactionsResponse.getContent()); assertEquals(2, pageItemsList.size()); GetSavingsAccountTransactionsPageItem first = pageItemsList.get(0); @@ -356,59 +298,59 @@ public void testSavingsTransactionsSearchWithFiltersSortingAndPagination() { @Test public void testSavingsTransactionsSearchDateValidationError() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); + Long savingsId = createClientWithSavingsAccount(); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "50", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "400", thirdDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "200", fourthDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "50", withdrawDate); + deposit(savingsId, "400", thirdDepositDate); + deposit(savingsId, "200", fourthDepositDate); - int typeD = SavingsAccountTransactionType.DEPOSIT.getId(); TransactionSearchRequest searchParameters = new TransactionSearchRequest().fromAmount(BigDecimal.valueOf(100)) .toAmount(BigDecimal.valueOf(500)); Map queryParams = buildTransactionsSearchQuery(searchParameters, null, null); - queryParams.put("fromDate", "05 May 2023"); // wrong date format - this.savingsAccountHelperValidationError.searchSavingsTransactions(savingsId, queryParams); + queryParams.put("fromDate", firstDepositDate); // wrong date format, no dateFormat parameter was sent + + assertEquals(BAD_REQUEST, savingsTransactionHelper.searchTransactionsExpectingErrorStatus(savingsId, queryParams)); } @Test public void testSavingsTransactionsSearchTransactionAmountValidationError() { - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", firstDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", secondDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "50", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "400", thirdDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "200", fourthDepositDate, CommonConstants.RESPONSE_RESOURCE_ID); - - TransactionSearchRequest searchParameters = new TransactionSearchRequest(); - Map queryParams = buildTransactionsSearchQuery(searchParameters, null, null); - queryParams.put("fromAmount", "test"); // not number - responseSpecForValidationError.statusCode(404); - this.savingsAccountHelperValidationError.searchSavingsTransactions(savingsId, queryParams); + Long savingsId = createClientWithSavingsAccount(); + + deposit(savingsId, "100", firstDepositDate); + deposit(savingsId, "300", secondDepositDate); + withdraw(savingsId, "50", withdrawDate); + deposit(savingsId, "400", thirdDepositDate); + deposit(savingsId, "200", fourthDepositDate); + + Map queryParams = buildTransactionsSearchQuery(new TransactionSearchRequest(), null, null); + queryParams.put("fromAmount", "test"); // not a number + + assertEquals(NOT_FOUND, savingsTransactionHelper.searchTransactionsExpectingErrorStatus(savingsId, queryParams)); } - private Integer createSavingsAccountDailyPosting(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - 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); - return savingsId; + private Long createClientWithSavingsAccount() { + Long clientId = createClient(startDate); + assertNotNull(clientId); + return createSavingsAccountDailyPosting(clientId, startDate); } - private Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private Long createSavingsAccountDailyPosting(final Long clientId, final String startDate) { + PostSavingsProductsResponse savingsProduct = createSavingsProduct( + SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, + SavingsTestData.InterestPostingPeriodType.DAILY, SavingsTestData.InterestCalculationType.DAILY_BALANCE)); + 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 Map buildTransactionsSearchQuery(TransactionSearchRequest searchParams, String dateFormat, Locale locale) { @@ -460,11 +402,9 @@ private Map buildTransactionsSearchQuery(TransactionSearchReques return params; } - // Reset configuration fields @AfterEach public void tearDown() { globalConfigurationHelper.resetAllDefaultGlobalConfigurations(); globalConfigurationHelper.verifyAllDefaultGlobalConfigurations(); } - } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualAccountingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualAccountingIntegrationTest.java index 309e6abf8ec..9e418d4ad25 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualAccountingIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualAccountingIntegrationTest.java @@ -18,246 +18,201 @@ */ package org.apache.fineract.integrationtests; -import static org.apache.fineract.integrationtests.common.BusinessDateHelper.runAt; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; -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.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.Map; -import org.apache.fineract.integrationtests.common.ClientHelper; -import org.apache.fineract.integrationtests.common.CommonConstants; -import org.apache.fineract.integrationtests.common.SchedulerJobHelper; -import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.client.models.JournalEntryTransactionItem; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +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.apache.fineract.integrationtests.common.accounting.Account; -import org.apache.fineract.integrationtests.common.accounting.AccountHelper; -import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsTestLifecycleExtension; -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; - -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccrualAccountingIntegrationTest { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsAccrualAccountingIntegrationTest.class); - private ResponseSpecification responseSpec; - private RequestSpecification requestSpec; - private SavingsAccountHelper savingsAccountHelper; - private SchedulerJobHelper schedulerJobHelper; - private JournalEntryHelper journalEntryHelper; - private AccountHelper accountHelper; - - @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.schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); - this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec); - this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); - } + +public class SavingsAccrualAccountingIntegrationTest extends FeignSavingsTestBase { + + private static final String ACCRUAL_JOB = "Add Accrual Transactions For Savings"; + private static final String SAVINGS_TRANSACTION_ID_PREFIX = "S"; + private static final String DEBIT = "DEBIT"; + private static final String CREDIT = "CREDIT"; + + private static final String BUSINESS_DATE = "2021-08-12"; + private static final LocalDate TODAY = LocalDate.of(2021, 8, 12); + private static final int DAYS_TO_SUBTRACT = 10; + private static final String CLIENT_ACTIVATION_DATE = "01 January 2020"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US); + + private static final String AMOUNT = "10000"; + private static final Double INTEREST_RATE = 10.0; + private static final Double OVERDRAFT_INTEREST_RATE = 21.0; + private static final BigDecimal OVERDRAFT_LIMIT = new BigDecimal("10000"); + + private static final BigDecimal PERCENT = new BigDecimal("100"); + private static final int DIGITS_AFTER_DECIMAL = 4; @Test public void testPositiveAccrualPostsCorrectJournalEntries() { - runAt("12 August 2021", () -> { - // --- ARRANGE --- - LOG.info("------------------------- INITIATING POSITIVE ACCRUAL ACCOUNTING TEST -------------------------"); - final int daysToSubtract = 10; - final String amount = "10000"; - - final Account savingsReferenceAccount = this.accountHelper.createAssetAccount("Savings Reference"); - final Account interestOnSavingsAccount = this.accountHelper.createExpenseAccount("Interest on Savings (Expense)"); - final Account savingsControlAccount = this.accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = this.accountHelper.createLiabilityAccount("Interest Payable (Liability)"); - final Account incomeFromFeesAccount = this.accountHelper.createIncomeAccount("Income from Fees"); - final Account[] accountList = { savingsReferenceAccount, savingsControlAccount, interestOnSavingsAccount, - interestPayableAccount, incomeFromFeesAccount }; - - final SavingsProductHelper productHelper = new SavingsProductHelper().withNominalAnnualInterestRate(new BigDecimal("10.0")) - .withAccountingRuleAsAccrualBased(accountList) - .withSavingsReferenceAccountId(savingsReferenceAccount.getAccountID().toString()) - .withSavingsControlAccountId(savingsControlAccount.getAccountID().toString()) - .withInterestOnSavingsAccountId(interestOnSavingsAccount.getAccountID().toString()) - .withInterestPayableAccountId(interestPayableAccount.getAccountID().toString()) - .withIncomeFromFeeAccountId(incomeFromFeesAccount.getAccountID().toString()); - - final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec, - this.responseSpec); - Assertions.assertNotNull(savingsProductId, "Failed to create savings product."); - - final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020"); - final LocalDate startDate = LocalDate.of(2021, 8, 12).minusDays(daysToSubtract); - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); - final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId, - SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); - this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString); - this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); - this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, amount, startDateString, - CommonConstants.RESPONSE_RESOURCE_ID); - - // --- ACT --- - schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - - // --- ASSERT --- - List accrualTransactions = getAccrualTransactions(savingsAccountId); - Assertions.assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found."); - - Number firstTransactionIdNumber = (Number) accrualTransactions.get(0).get("id"); - ArrayList journalEntries = journalEntryHelper - .getJournalEntriesByTransactionId("S" + firstTransactionIdNumber.intValue()); - Assertions.assertFalse(journalEntries.isEmpty(), "No journal entries found for positive accrual."); - - boolean debitFound = false; - boolean creditFound = false; - for (Map entry : journalEntries) { - String entryType = (String) ((HashMap) entry.get("entryType")).get("value"); - Integer accountId = ((Number) entry.get("glAccountId")).intValue(); - if ("DEBIT".equals(entryType) && accountId.equals(interestOnSavingsAccount.getAccountID())) { - debitFound = true; - } - if ("CREDIT".equals(entryType) && accountId.equals(interestPayableAccount.getAccountID())) { - creditFound = true; - } - } - - Assertions.assertTrue(debitFound, "DEBIT to Interest on Savings (Expense) Account not found for positive accrual."); - Assertions.assertTrue(creditFound, "CREDIT to Interest Payable (Liability) Account not found for positive accrual."); - - BigDecimal interest = getCalculateAccrualsForDay(productHelper, amount); - - for (HashMap accrual : accrualTransactions) { - BigDecimal amountAccrualTransaccion = BigDecimal.valueOf((Double) accrual.get("amount")); - Assertions.assertEquals(interest, amountAccrualTransaccion); - } - LOG.info("VALIDATE AMOUNT AND ACCOUNT"); + businessDateHelper.runAt(BUSINESS_DATE, () -> { + final AccrualAccounts accounts = createDistinctAccrualAccounts(); + + final PostSavingsProductsRequest product = withMappings(accrualProduct(INTEREST_RATE), accounts); + + final Long savingsProductId = savingsProductHelper.createSavingsProduct(product).getResourceId(); + assertNotNull(savingsProductId, "Failed to create savings product."); + + final Long savingsAccountId = createActiveSavingsAccount(savingsProductId); + deposit(savingsAccountId, AMOUNT, startDateString()); + + schedulerHelper.executeAndAwaitJob(ACCRUAL_JOB); + + final List accrualTransactions = savingsTransactionHelper + .getAccrualTransactions(savingsAccountId); + assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found."); + + final List journalEntries = journalEntriesOf(accrualTransactions.get(0)); + assertFalse(journalEntries.isEmpty(), "No journal entries found for positive accrual."); + assertHasEntry(journalEntries, DEBIT, accounts.interestOnSavings(), + "DEBIT to Interest on Savings (Expense) Account not found for positive accrual."); + assertHasEntry(journalEntries, CREDIT, accounts.interestPayable(), + "CREDIT to Interest Payable (Liability) Account not found for positive accrual."); + + verifyEveryAccrualIsOneDayOfInterest(accrualTransactions, INTEREST_RATE); }); } @Test public void testNegativeAccrualPostsCorrectJournalEntries() { - runAt("12 August 2021", () -> { - // --- ARRANGE --- - LOG.info("------------------------- INITIATING NEGATIVE ACCRUAL (OVERDRAFT) ACCOUNTING TEST -------------------------"); - final int daysToSubtract = 10; - final String amount = "10000"; - - final Account savingsReferenceAccount = this.accountHelper.createAssetAccount("Savings Reference"); - final Account overdraftPortfolioControl = this.accountHelper.createAssetAccount("Overdraft Portfolio"); - final Account interestReceivableAccount = this.accountHelper.createAssetAccount("Interest Receivable (Asset)"); - final Account savingsControlAccount = this.accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = this.accountHelper.createLiabilityAccount("Interest Payable"); - final Account overdraftInterestIncomeAccount = this.accountHelper.createIncomeAccount("Overdraft Interest Income"); - final Account expenseAccount = this.accountHelper.createExpenseAccount("Interest on Savings (Expense)"); - - final Account[] accountList = { savingsReferenceAccount, savingsControlAccount, expenseAccount, - overdraftInterestIncomeAccount }; - - final String overdraftLimit = "10000"; - final String overdraftInterestRate = "21.0"; - final SavingsProductHelper productHelper = new SavingsProductHelper() - .withNominalAnnualInterestRate(new BigDecimal(overdraftInterestRate)).withAccountingRuleAsAccrualBased(accountList) - .withOverDraftRate(overdraftLimit, overdraftInterestRate) - .withSavingsReferenceAccountId(savingsReferenceAccount.getAccountID().toString()) - .withSavingsControlAccountId(savingsControlAccount.getAccountID().toString()) - .withInterestReceivableAccountId(interestReceivableAccount.getAccountID().toString()) - .withIncomeFromInterestId(overdraftInterestIncomeAccount.getAccountID().toString()) - .withInterestPayableAccountId(interestPayableAccount.getAccountID().toString()) - .withInterestOnSavingsAccountId(expenseAccount.getAccountID().toString()) - .withOverdraftPortfolioControlId(overdraftPortfolioControl.getAccountID().toString()); - - final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec, - this.responseSpec); - Assertions.assertNotNull(savingsProductId, "Savings product with overdraft creation failed."); - - final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020"); - final LocalDate startDate = LocalDate.of(2021, 8, 12).minusDays(daysToSubtract); - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); - final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId, - SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); - this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString); - this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "10000", startDateString, - CommonConstants.RESPONSE_RESOURCE_ID); - - // --- ACT --- - schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - - // --- ASSERT --- - List accrualTransactions = getAccrualTransactions(savingsAccountId); - Assertions.assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found for overdraft."); - - Number firstTransactionIdNumber = (Number) accrualTransactions.get(0).get("id"); - ArrayList journalEntries = journalEntryHelper - .getJournalEntriesByTransactionId("S" + firstTransactionIdNumber.intValue()); - Assertions.assertFalse(journalEntries.isEmpty(), "No journal entries found for negative accrual."); - - boolean debitFound = false; - boolean creditFound = false; - for (Map entry : journalEntries) { - String entryType = (String) ((HashMap) entry.get("entryType")).get("value"); - Integer accountId = ((Number) entry.get("glAccountId")).intValue(); - if ("DEBIT".equals(entryType) && accountId.equals(interestReceivableAccount.getAccountID())) { - debitFound = true; - } - if ("CREDIT".equals(entryType) && accountId.equals(overdraftInterestIncomeAccount.getAccountID())) { - creditFound = true; - } - } - - Assertions.assertTrue(debitFound, "DEBIT to Interest Receivable (Asset) Account not found for negative accrual."); - Assertions.assertTrue(creditFound, "CREDIT to Overdraft Interest Income Account not found for negative accrual."); - - BigDecimal interest = getCalculateAccrualsForDay(productHelper, amount); - - for (HashMap accrual : accrualTransactions) { - BigDecimal amountAccrualTransaccion = BigDecimal.valueOf((Double) accrual.get("amount")); - Assertions.assertEquals(interest, amountAccrualTransaccion); - } - LOG.info("VALIDATE AMOUNT AND ACCOUNT"); + businessDateHelper.runAt(BUSINESS_DATE, () -> { + final AccrualAccounts accounts = createDistinctAccrualAccounts(); + + final PostSavingsProductsRequest product = withMappings(accrualProduct(OVERDRAFT_INTEREST_RATE).allowOverdraft(true), accounts) + .overdraftLimit(OVERDRAFT_LIMIT)// + .nominalAnnualInterestRateOverdraft(BigDecimal.valueOf(OVERDRAFT_INTEREST_RATE)); + + final Long savingsProductId = savingsProductHelper.createSavingsProduct(product).getResourceId(); + assertNotNull(savingsProductId, "Savings product with overdraft creation failed."); + + final Long savingsAccountId = createActiveSavingsAccount(savingsProductId); + withdraw(savingsAccountId, AMOUNT, startDateString()); + + schedulerHelper.executeAndAwaitJob(ACCRUAL_JOB); + + final List accrualTransactions = savingsTransactionHelper + .getAccrualTransactions(savingsAccountId); + assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found for overdraft."); + + final List journalEntries = journalEntriesOf(accrualTransactions.get(0)); + assertFalse(journalEntries.isEmpty(), "No journal entries found for negative accrual."); + assertHasEntry(journalEntries, DEBIT, accounts.interestReceivable(), + "DEBIT to Interest Receivable (Asset) Account not found for negative accrual."); + assertHasEntry(journalEntries, CREDIT, accounts.incomeFromInterest(), + "CREDIT to Overdraft Interest Income Account not found for negative accrual."); + + verifyEveryAccrualIsOneDayOfInterest(accrualTransactions, OVERDRAFT_INTEREST_RATE); }); } - private List getAccrualTransactions(Integer savingsAccountId) { - List allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId); - List accrualTransactions = new ArrayList<>(); - for (HashMap transaction : allTransactions) { - Map type = (Map) transaction.get("transactionType"); - if (type != null && Boolean.TRUE.equals(type.get("accrual"))) { - accrualTransactions.add(transaction); - } + private void verifyEveryAccrualIsOneDayOfInterest(final List accrualTransactions, + final Double interestRate) { + final BigDecimal expectedDailyInterest = dailyInterest(interestRate); + for (SavingsAccountTransactionData accrual : accrualTransactions) { + SavingsTestValidators.verifyAmount(expectedDailyInterest, accrual.getAmount(), "Verifying the accrual of " + accrual.getDate()); } - return accrualTransactions; } - private BigDecimal getCalculateAccrualsForDay(SavingsProductHelper productHelper, String amount) { - BigDecimal interest = BigDecimal.ZERO; - BigDecimal interestRateAsFraction = productHelper.getNominalAnnualInterestRate().divide(new BigDecimal(100.00)); - BigDecimal realBalanceForInterestCalculation = new BigDecimal(amount); - - final BigDecimal multiplicand = BigDecimal.ONE.divide(productHelper.getInterestCalculationDaysInYearType(), MathContext.DECIMAL64); + /** Ordered the way the server does it: the annual rate becomes a daily one before it is applied. */ + private BigDecimal dailyInterest(final Double interestRate) { + final BigDecimal interestRateAsFraction = BigDecimal.valueOf(interestRate).divide(PERCENT); + final BigDecimal multiplicand = BigDecimal.ONE + .divide(BigDecimal.valueOf(SavingsTestData.InterestCalculationDaysInYearType.DAYS_365), MathContext.DECIMAL64); final BigDecimal dailyInterestRate = interestRateAsFraction.multiply(multiplicand, MathContext.DECIMAL64); - final BigDecimal periodicInterestRate = dailyInterestRate.multiply(BigDecimal.valueOf(1), MathContext.DECIMAL64); - interest = realBalanceForInterestCalculation.multiply(periodicInterestRate, MathContext.DECIMAL64) - .setScale(productHelper.getDecimalCurrency(), RoundingMode.HALF_EVEN); + return new BigDecimal(AMOUNT).multiply(dailyInterestRate, MathContext.DECIMAL64).setScale(DIGITS_AFTER_DECIMAL, + RoundingMode.HALF_EVEN); + } + + private List journalEntriesOf(final SavingsAccountTransactionData transaction) { + final List pageItems = journalEntryHelper + .getJournalEntriesByTransactionId(SAVINGS_TRANSACTION_ID_PREFIX + transaction.getId()).getPageItems(); + return pageItems == null ? List.of() : pageItems; + } + + private void assertHasEntry(final List journalEntries, final String entryType, final Account account, + final String message) { + final Long expectedAccountId = SavingsRequestBuilders.accountId(account); + assertTrue(journalEntries.stream().anyMatch(entry -> entry.getEntryType() != null + && entryType.equals(entry.getEntryType().getValue()) && expectedAccountId.equals(entry.getGlAccountId())), message); + } + + /** + * Every mapping gets its own account so that a journal entry posted against the wrong one cannot satisfy an + * assertion aimed at another. + */ + private AccrualAccounts createDistinctAccrualAccounts() { + return new AccrualAccounts(accountHelper.createAssetAccount("Savings Reference"), + accountHelper.createAssetAccount("Overdraft Portfolio Control"), accountHelper.createAssetAccount("Fees Receivable"), + accountHelper.createAssetAccount("Penalties Receivable"), accountHelper.createAssetAccount("Interest Receivable (Asset)"), + accountHelper.createLiabilityAccount("Savings Control"), accountHelper.createLiabilityAccount("Transfers In Suspense"), + accountHelper.createLiabilityAccount("Interest Payable (Liability)"), accountHelper.createIncomeAccount("Income from Fees"), + accountHelper.createIncomeAccount("Income from Penalties"), accountHelper.createIncomeAccount("Overdraft Interest Income"), + accountHelper.createExpenseAccount("Interest on Savings (Expense)"), accountHelper.createExpenseAccount("Write Off")); + } + + private PostSavingsProductsRequest withMappings(final PostSavingsProductsRequest request, final AccrualAccounts accounts) { + return request// + .savingsReferenceAccountId(SavingsRequestBuilders.accountId(accounts.savingsReference()))// + .overdraftPortfolioControlId(SavingsRequestBuilders.accountId(accounts.overdraftPortfolioControl()))// + .feesReceivableAccountId(SavingsRequestBuilders.accountId(accounts.feesReceivable()))// + .penaltiesReceivableAccountId(SavingsRequestBuilders.accountId(accounts.penaltiesReceivable()))// + .interestReceivableAccountId(SavingsRequestBuilders.accountId(accounts.interestReceivable()))// + .savingsControlAccountId(SavingsRequestBuilders.accountId(accounts.savingsControl()))// + .transfersInSuspenseAccountId(SavingsRequestBuilders.accountId(accounts.transfersInSuspense()))// + .interestPayableAccountId(SavingsRequestBuilders.accountId(accounts.interestPayable()))// + .incomeFromFeeAccountId(SavingsRequestBuilders.accountId(accounts.incomeFromFee()))// + .incomeFromPenaltyAccountId(SavingsRequestBuilders.accountId(accounts.incomeFromPenalty()))// + .incomeFromInterestId(SavingsRequestBuilders.accountId(accounts.incomeFromInterest()))// + .interestOnSavingsAccountId(SavingsRequestBuilders.accountId(accounts.interestOnSavings()))// + .writeOffAccountId(SavingsRequestBuilders.accountId(accounts.writeOff())); + } + + private record AccrualAccounts(Account savingsReference, Account overdraftPortfolioControl, Account feesReceivable, + Account penaltiesReceivable, Account interestReceivable, Account savingsControl, Account transfersInSuspense, + Account interestPayable, Account incomeFromFee, Account incomeFromPenalty, Account incomeFromInterest, + Account interestOnSavings, Account writeOff) { + } + + private Long createActiveSavingsAccount(final Long savingsProductId) { + final Long clientId = createClient(CLIENT_ACTIVATION_DATE); + assertNotNull(clientId); + + final String startDateString = startDateString(); + final Long savingsAccountId = submitSavingsApplication(clientId, savingsProductId, startDateString).getSavingsId(); + assertNotNull(savingsAccountId); + + approveSavings(savingsAccountId, startDateString); + activateSavings(savingsAccountId, startDateString); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsAccountId)); + return savingsAccountId; + } + + private String startDateString() { + return DATE_FORMATTER.format(TODAY.minusDays(DAYS_TO_SUBTRACT)); + } - return interest; + private PostSavingsProductsRequest accrualProduct(final Double nominalAnnualInterestRate) { + return SavingsRequestBuilders + .savingsProduct(SavingsTestData.InterestCompoundingPeriodType.MONTHLY, SavingsTestData.InterestPostingPeriodType.MONTHLY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE) + .nominalAnnualInterestRate(nominalAnnualInterestRate)// + .accountingRule(SavingsTestData.AccountingRule.ACCRUAL_PERIODIC); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java index 23833105922..fb156cc29c7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java @@ -18,247 +18,159 @@ */ package org.apache.fineract.integrationtests; -import static org.apache.fineract.integrationtests.common.BusinessDateHelper.runAt; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; -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.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import org.apache.fineract.integrationtests.common.ClientHelper; -import org.apache.fineract.integrationtests.common.CommonConstants; -import org.apache.fineract.integrationtests.common.SchedulerJobHelper; -import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +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.apache.fineract.integrationtests.common.accounting.Account; -import org.apache.fineract.integrationtests.common.accounting.AccountHelper; -import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; -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.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; -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsAccrualIntegrationTest { +public class SavingsAccrualIntegrationTest extends FeignSavingsTestBase { - private static final Logger LOG = LoggerFactory.getLogger(SavingsAccrualIntegrationTest.class); - private ResponseSpecification responseSpec; - private RequestSpecification requestSpec; - private SavingsAccountHelper savingsAccountHelper; - private SchedulerJobHelper schedulerJobHelper; - private JournalEntryHelper journalEntryHelper; - private AccountHelper accountHelper; + private static final String ACCRUAL_JOB = "Add Accrual Transactions For Savings"; - @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.schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); - this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec); - this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); - } + private static final String BUSINESS_DATE = "2021-08-12"; + private static final LocalDate TODAY = LocalDate.of(2021, 8, 12); + private static final String CLIENT_ACTIVATION_DATE = "01 January 2020"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US); + + private static final Double INTEREST_RATE = 10.0; + private static final String DEPOSIT_AMOUNT = "10000"; + private static final int DAYS_TO_TEST = 10; + private static final int DAYS_UNTIL_TRANSACTION = 5; + private static final BigDecimal DAYS_IN_YEAR = new BigDecimal("365"); + private static final BigDecimal PERCENT = new BigDecimal("100"); @Test public void testAccrualsAreGeneratedForTenDayPeriod() { - runAt("12 August 2021", () -> { - // --- ARRANGE --- - - final Account assetAccount = this.accountHelper.createAssetAccount(); - final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); - final Account incomeAccount = this.accountHelper.createIncomeAccount(); - final Account expenseAccount = this.accountHelper.createExpenseAccount(); - final String interestRate = "10.0"; - final int daysToTest = 10; - - final SavingsProductHelper productHelper = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() - .withNominalAnnualInterestRate(new BigDecimal(interestRate)) - .withAccountingRuleAsAccrualBased(new Account[] { assetAccount, liabilityAccount, incomeAccount, expenseAccount }); - - final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec, - this.responseSpec); - Assertions.assertNotNull(savingsProductId, "Error creating savings product."); + businessDateHelper.runAt(BUSINESS_DATE, () -> { + final LocalDate startDate = TODAY.minusDays(DAYS_TO_TEST); + final Long savingsAccountId = createFundedSavingsAccount(startDate); - final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020"); - Assertions.assertNotNull(clientId, "Error creating client."); + schedulerHelper.executeAndAwaitJob(ACCRUAL_JOB); - final LocalDate startDate = LocalDate.of(2021, 8, 12).minusDays(daysToTest); - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + final List accrualTransactions = savingsTransactionHelper + .getAccrualTransactions(savingsAccountId); + assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found."); - final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId, - SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); - Assertions.assertNotNull(savingsAccountId, "Error applying for savings account."); + final long daysBetween = ChronoUnit.DAYS.between(startDate, TODAY); + final long actualNumberOfTransactions = accrualTransactions.size(); + assertTrue(actualNumberOfTransactions >= daysBetween && actualNumberOfTransactions <= daysBetween + 1, "For a period of " + + daysBetween + " days, a close number of transactions was expected, but found " + actualNumberOfTransactions); - this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString); - this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); + final BigDecimal expectedTotalAccrual = dailyInterest(new BigDecimal(DEPOSIT_AMOUNT), 8) + .multiply(BigDecimal.valueOf(actualNumberOfTransactions)).setScale(2, RoundingMode.HALF_EVEN); - final HashMap savingsStatus = SavingsStatusChecker.getStatusOfSavings(this.requestSpec, this.responseSpec, - savingsAccountId); - SavingsStatusChecker.verifySavingsIsActive(savingsStatus); - - this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, - CommonConstants.RESPONSE_RESOURCE_ID); - - // --- ACT --- - schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - - // --- ASSERT --- - List allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId); - List accrualTransactions = new ArrayList<>(); - for (HashMap transaction : allTransactions) { - Map type = (Map) transaction.get("transactionType"); - if (type != null && Boolean.TRUE.equals(type.get("accrual"))) { - accrualTransactions.add(transaction); - } - } - Assertions.assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found."); - - long daysBetween = ChronoUnit.DAYS.between(startDate, LocalDate.of(2021, 8, 12)); - long actualNumberOfTransactions = accrualTransactions.size(); - - Assertions.assertTrue(actualNumberOfTransactions >= daysBetween && actualNumberOfTransactions <= daysBetween + 1, - "For a period of " + daysBetween + " days, a close number of transactions was expected, but found " - + actualNumberOfTransactions); - - BigDecimal principal = new BigDecimal("10000"); - BigDecimal rate = new BigDecimal(interestRate).divide(new BigDecimal(100)); - BigDecimal daysInYear = new BigDecimal("365"); - - BigDecimal expectedTotalAccrual = principal.multiply(rate).divide(daysInYear, 8, RoundingMode.HALF_EVEN) - .multiply(new BigDecimal(actualNumberOfTransactions)).setScale(2, RoundingMode.HALF_EVEN); - - BigDecimal actualTotalAccrual = savingsAccountHelper.getTotalAccrualAmount(savingsAccountId); - - Assertions.assertEquals(0, expectedTotalAccrual.compareTo(actualTotalAccrual), - "The total accrual (" + actualTotalAccrual + ") does not match the expected (" + expectedTotalAccrual + ")"); + SavingsTestValidators.verifyAmount(expectedTotalAccrual, savingsTransactionHelper.getTotalAccrualAmount(savingsAccountId), + "Verifying the total accrual"); }); } @Test public void testAccrualsAreReversedAndRecalculatedAfterBackdatedTransaction() { - runAt("12 August 2021", () -> { - // --- ARRANGE --- - final Account assetAccount = this.accountHelper.createAssetAccount(); - final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); - final Account incomeAccount = this.accountHelper.createIncomeAccount(); - final Account expenseAccount = this.accountHelper.createExpenseAccount(); - final String interestRate = "10.0"; - final int daysToTest = 10; - final int daysUntilTransaction = 5; - - final SavingsProductHelper productHelper = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() - .withNominalAnnualInterestRate(new BigDecimal(interestRate)) - .withAccountingRuleAsAccrualBased(new Account[] { assetAccount, liabilityAccount, incomeAccount, expenseAccount }); - - final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec, - this.responseSpec); - Assertions.assertNotNull(savingsProductId); - - final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020"); - Assertions.assertNotNull(clientId); + businessDateHelper.runAt(BUSINESS_DATE, () -> { + final LocalDate startDate = TODAY.minusDays(DAYS_TO_TEST); + final Long savingsAccountId = createFundedSavingsAccount(startDate); - final LocalDate today = LocalDate.of(2021, 8, 12); - final LocalDate startDate = today.minusDays(daysToTest); - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + schedulerHelper.executeAndAwaitJob(ACCRUAL_JOB); - final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId, - SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); - Assertions.assertNotNull(savingsAccountId); + final LocalDate backdatedTransactionDate = startDate.plusDays(DAYS_UNTIL_TRANSACTION); + final BigDecimal withdrawalAmount = new BigDecimal("1000"); + withdraw(savingsAccountId, withdrawalAmount.toPlainString(), DATE_FORMATTER.format(backdatedTransactionDate)); - this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString); - this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); - this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, - CommonConstants.RESPONSE_RESOURCE_ID); + schedulerHelper.executeAndAwaitJob(ACCRUAL_JOB); - // --- ACT --- - schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); + final List accrualTransactions = savingsTransactionHelper + .getAccrualTransactions(savingsAccountId); + verifyAccrualsWereReversedFrom(backdatedTransactionDate, accrualTransactions); - final LocalDate backdatedTransactionDate = startDate.plusDays(daysUntilTransaction); - final String backdatedTransactionDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US) - .format(backdatedTransactionDate); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "1000", backdatedTransactionDateString, - CommonConstants.RESPONSE_RESOURCE_ID); + final BigDecimal remainingBalance = new BigDecimal(DEPOSIT_AMOUNT).subtract(withdrawalAmount); + final BigDecimal expectedDailyInterest = dailyInterest(remainingBalance, 4); - schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - - // --- ASSERT --- - List allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId); - - Map> accrualsByDate = new HashMap<>(); - - for (HashMap transaction : allTransactions) { - Map type = (Map) transaction.get("transactionType"); - - if (type != null && Boolean.TRUE.equals(type.get("accrual"))) { - List dateArray = (List) transaction.get("date"); - LocalDate transactionDate = LocalDate.of(dateArray.get(0).intValue(), dateArray.get(1).intValue(), - dateArray.get(2).intValue()); - boolean isReversed = Boolean.TRUE.equals(transaction.get("reversed")); - - accrualsByDate.putIfAbsent(transactionDate, new HashMap<>(Map.of("TOTAL", 0, "REVERSED", 0))); - - Map counts = accrualsByDate.get(transactionDate); - counts.put("TOTAL", counts.get("TOTAL") + 1); - if (isReversed) { - counts.put("REVERSED", counts.get("REVERSED") + 1); - } + boolean newAccrualVerified = false; + for (SavingsAccountTransactionData accrual : accrualTransactions) { + if (!Boolean.TRUE.equals(accrual.getReversed()) && !accrual.getDate().isBefore(backdatedTransactionDate)) { + SavingsTestValidators.verifyAmount(expectedDailyInterest, accrual.getAmount().setScale(4, RoundingMode.HALF_EVEN), + "Verifying the recalculated accrual of " + accrual.getDate()); + newAccrualVerified = true; } } + assertTrue(newAccrualVerified, "Could not verify the mathematical calculation of a new accrual."); + }); + } - for (Map.Entry> entry : accrualsByDate.entrySet()) { - LocalDate date = entry.getKey(); - Map counts = entry.getValue(); - Integer total = counts.get("TOTAL"); - Integer reversed = counts.get("REVERSED"); - - if (date.isBefore(backdatedTransactionDate)) { - Assertions.assertEquals(1, total, "There should be 1 accrual for the date " + date); - Assertions.assertEquals(0, reversed, "The accrual for the date " + date + " should not be reversed."); - } else { - Assertions.assertEquals(2, total, "There should be 2 accruals (original and new) for the date " + date); - Assertions.assertEquals(1, reversed, "There should be 1 reversed accrual for the date " + date); - } + /** Dates before the backdated transaction keep one accrual; dates from it carry the reversed one plus a new one. */ + private void verifyAccrualsWereReversedFrom(final LocalDate backdatedTransactionDate, + final List accrualTransactions) { + final Map accrualsByDate = new HashMap<>(); + final Map reversedAccrualsByDate = new HashMap<>(); + for (SavingsAccountTransactionData accrual : accrualTransactions) { + accrualsByDate.merge(accrual.getDate(), 1L, Long::sum); + if (Boolean.TRUE.equals(accrual.getReversed())) { + reversedAccrualsByDate.merge(accrual.getDate(), 1L, Long::sum); + } + } + assertFalse(accrualsByDate.isEmpty(), "No accrual transactions were found to verify."); + + for (Map.Entry entry : accrualsByDate.entrySet()) { + final LocalDate date = entry.getKey(); + final long reversed = reversedAccrualsByDate.getOrDefault(date, 0L); + if (date.isBefore(backdatedTransactionDate)) { + assertEquals(1L, entry.getValue(), "There should be 1 accrual for the date " + date); + assertEquals(0L, reversed, "The accrual for the date " + date + " should not be reversed."); + } else { + assertEquals(2L, entry.getValue(), "There should be 2 accruals (original and new) for the date " + date); + assertEquals(1L, reversed, "There should be 1 reversed accrual for the date " + date); } - Assertions.assertFalse(accrualsByDate.isEmpty(), "No accrual transactions were found to verify."); - BigDecimal expectedDailyInterestOn9k = new BigDecimal("9000").multiply(new BigDecimal("0.10")).divide(new BigDecimal("365"), 4, - RoundingMode.HALF_EVEN); + } + } - boolean newAccrualVerified = false; - for (HashMap transaction : allTransactions) { - Map type = (Map) transaction.get("transactionType"); - if (type != null && Boolean.TRUE.equals(type.get("accrual")) && !Boolean.TRUE.equals(transaction.get("reversed"))) { - List dateArray = (List) transaction.get("date"); - LocalDate transactionDate = LocalDate.of(dateArray.get(0).intValue(), dateArray.get(1).intValue(), - dateArray.get(2).intValue()); + private BigDecimal dailyInterest(final BigDecimal balance, final int scale) { + return balance.multiply(BigDecimal.valueOf(INTEREST_RATE)).divide(PERCENT.multiply(DAYS_IN_YEAR), scale, RoundingMode.HALF_EVEN); + } - if (!transactionDate.isBefore(backdatedTransactionDate)) { - BigDecimal actualAmount = new BigDecimal(transaction.get("amount").toString()).setScale(4, RoundingMode.HALF_EVEN); - Assertions.assertEquals(0, expectedDailyInterestOn9k.compareTo(actualAmount), "The new accrual amount (" - + actualAmount + ") does not match the expected (" + expectedDailyInterestOn9k + ")"); - newAccrualVerified = true; - } - } - } - Assertions.assertTrue(newAccrualVerified, "Could not verify the mathematical calculation of a new accrual."); - }); + private Long createFundedSavingsAccount(final LocalDate startDate) { + final Account assetAccount = accountHelper.createAssetAccount("assetAccount"); + final Account liabilityAccount = accountHelper.createLiabilityAccount("liabilityAccount"); + final Account incomeAccount = accountHelper.createIncomeAccount("incomeAccount"); + final Account expenseAccount = accountHelper.createExpenseAccount("expenseAccount"); + + final Long savingsProductId = savingsProductHelper.createSavingsProduct(SavingsRequestBuilders.withAccrualAccountingMappings( + SavingsRequestBuilders + .savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, + SavingsTestData.InterestPostingPeriodType.MONTHLY, SavingsTestData.InterestCalculationType.DAILY_BALANCE) + .nominalAnnualInterestRate(INTEREST_RATE)// + .accountingRule(SavingsTestData.AccountingRule.ACCRUAL_PERIODIC), + assetAccount, liabilityAccount, incomeAccount, expenseAccount)).getResourceId(); + assertNotNull(savingsProductId, "Error creating savings product."); + + final Long clientId = createClient(CLIENT_ACTIVATION_DATE); + assertNotNull(clientId, "Error creating client."); + + final String startDateString = DATE_FORMATTER.format(startDate); + final Long savingsAccountId = submitSavingsApplication(clientId, savingsProductId, startDateString).getSavingsId(); + assertNotNull(savingsAccountId, "Error applying for savings account."); + + approveSavings(savingsAccountId, startDateString); + activateSavings(savingsAccountId, startDateString); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsAccountId)); + + deposit(savingsAccountId, DEPOSIT_AMOUNT, startDateString); + return savingsAccountId; } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingIntegrationTest.java index 79e5da293d9..8a22680049d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingIntegrationTest.java @@ -19,63 +19,32 @@ package org.apache.fineract.integrationtests; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -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.math.BigDecimal; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; +import java.time.Month; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; +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.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.integrationtests.common.BusinessDateHelper; -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.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.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.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 SavingsInterestPostingIntegrationTest { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsInterestPostingIntegrationTest.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 SavingsInterestPostingIntegrationTest extends FeignSavingsTestBase { + + private static final String START_DATE = "01 November 2021"; + private static final BigDecimal EXPECTED_INTEREST_POSTED = new BigDecimal("0.274"); + private static final LocalDate EXPECTED_INTEREST_POSTING_DATE = LocalDate.of(2021, Month.NOVEMBER, 2); @Test public void testSavingsDailyInterestPosting() { @@ -83,63 +52,56 @@ public void testSavingsDailyInterestPosting() { try { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, new PutGlobalConfigurationsRequest().enabled(true)); - BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, today); - // client activation, savings activation and 1st transaction date - final String startDate = "01 November 2021"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPosting(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> transactions = (ArrayList>) accountDetails.get("transactions"); - HashMap interestPostingTransaction = transactions.get(transactions.size() - 2); - for (Map.Entry entry : interestPostingTransaction.entrySet()) { - LOG.info("{} - {}", entry.getKey(), String.valueOf(entry.getValue())); - } - assertEquals("0.274", interestPostingTransaction.get("amount").toString(), "Equality check for interest posted amount"); - assertEquals("[2021, 11, 2]", interestPostingTransaction.get("date").toString(), "Date check for Interest Posting transaction"); - List submittedOnDateStringList = (List) interestPostingTransaction.get("submittedOnDate"); - LocalDate submittedOnDate = submittedOnDateStringList.stream().collect( - Collectors.collectingAndThen(Collectors.toList(), list -> LocalDate.of(list.get(0), list.get(1), list.get(2)))); - assertTrue(DateUtils.isEqual(submittedOnDate, today), "Submitted On Date check for Interest Posting transaction"); + businessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE.name(), today.toString()); + + Long clientId = createClient(START_DATE); + assertNotNull(clientId); + + Long savingsId = createSavingsAccountDailyPosting(clientId, START_DATE); + + deposit(savingsId, "1000", START_DATE); + + savingsHelper.postInterest(savingsId); + + List 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"); + assertTrue(DateUtils.isEqual(interestPostingTransaction.getSubmittedOnDate(), today), + "Submitted On Date check for Interest Posting transaction"); } finally { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, new PutGlobalConfigurationsRequest().enabled(false)); } - } - private Integer createSavingsAccountDailyPosting(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - 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 createSavingsAccountDailyPosting(final Long clientId, final String startDate) { + PostSavingsProductsResponse savingsProduct = createSavingsProductDailyPosting(); + 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 Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private PostSavingsProductsResponse createSavingsProductDailyPosting() { + return createSavingsProduct(SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, + SavingsTestData.InterestPostingPeriodType.DAILY, SavingsTestData.InterestCalculationType.DAILY_BALANCE)); } - // Reset configuration fields @AfterEach public void tearDown() { globalConfigurationHelper.resetAllDefaultGlobalConfigurations(); globalConfigurationHelper.verifyAllDefaultGlobalConfigurations(); } - } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java index 89569bd4736..7634d133174 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java @@ -19,356 +19,218 @@ package org.apache.fineract.integrationtests; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +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.math.BigDecimal; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; +import java.time.Month; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; -import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; -import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.integrationtests.common.BusinessDateHelper; -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.SchedulerJobHelper; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +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.apache.fineract.integrationtests.common.Utils; -import org.apache.fineract.integrationtests.common.accounting.AccountHelper; -import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; -import org.apache.fineract.integrationtests.common.charges.ChargesHelper; -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.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Order(2) -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsInterestPostingJobIntegrationTest { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsInterestPostingJobIntegrationTest.class); - public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; - public static final String POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME = "SA_PINT"; - - private static ResponseSpecification responseSpec; - private static RequestSpecification requestSpec; - private SavingsProductHelper savingsProductHelper; - private SavingsAccountHelper savingsAccountHelper; - private SchedulerJobHelper scheduleJobHelper; - private JournalEntryHelper journalEntryHelper; - private AccountHelper accountHelper; - 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.scheduleJobHelper = new SchedulerJobHelper(requestSpec); - this.globalConfigurationHelper = new GlobalConfigurationHelper(); - } + +public class SavingsInterestPostingJobIntegrationTest extends FeignSavingsTestBase { + + private static final String POST_INTEREST_JOB_NAME = "Post Interest For Savings"; + + private static final String START_DATE = "10 April 2022"; + private static final String JULY_START_DATE = "01 July 2022"; + private static final String CHARGE_START_DATE = "21 June 2022"; + private static final String DEPOSIT_AMOUNT = "10000"; + private static final BigDecimal OVERDRAFT_LIMIT = new BigDecimal("10000.0"); + private static final BigDecimal OVERDRAFT_INTEREST_RATE = new BigDecimal("10"); @Test public void testSavingsBalanceCheckAfterDailyInterestPostingJob() { - final String startDate = "10 April 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); - - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); - ArrayList> transactions = (ArrayList>) transactionObj; - HashMap interestPostingTransaction = transactions.get(transactions.size() - 48); - for (Map.Entry entry : interestPostingTransaction.entrySet()) { - LOG.info("{} - {}", entry.getKey(), String.valueOf(entry.getValue())); - } - assertEquals("10129.582", interestPostingTransaction.get("runningBalance").toString(), "Equality check for Balance"); + final Long savingsId = createActiveDailyPostingSavings(START_DATE); + deposit(savingsId, DEPOSIT_AMOUNT, START_DATE); + + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + final List transactions = savingsTransactionHelper.getTransactions(savingsId); + // The RestAssured version read this through JsonPath, which narrowed it to a float and saw "10129.582". + SavingsTestValidators.verifyAmount(new BigDecimal("10129.5818"), transactions.get(transactions.size() - 48).getRunningBalance(), + "Equality check for Balance"); } @Test public void testSavingsDailyInterestPostingJobWithAccountingNone() { - final String startDate = "10 April 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - this.accountHelper = new AccountHelper(requestSpec, responseSpec); - this.journalEntryHelper = new JournalEntryHelper(requestSpec, responseSpec); - - final Integer savingsId = createSavingsAccountDailyPostingWithAccounting(clientID, startDate); - - Integer transactionId = (Integer) this.savingsAccountHelper.depositToSavingsAccount(savingsId, "1000", startDate, - CommonConstants.RESPONSE_RESOURCE_ID); - ArrayList journalEntries = this.journalEntryHelper.getJournalEntriesByTransactionId(String.valueOf(transactionId)); - assertEquals(0, journalEntries.size()); + final Long savingsId = createActiveSavings(START_DATE, minimumOpeningBalanceProduct()); + + final Long transactionId = deposit(savingsId, "1000", START_DATE).getResourceId(); + assertNotNull(transactionId); + + assertEquals(0, journalEntryHelper.getJournalEntriesByTransactionId(String.valueOf(transactionId)).getPageItems().size(), + "A product with no accounting should not post journal entries"); } @Test public void testDuplicateOverdraftInterestPostingJob() { - final String startDate = "01 July 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPostingOverdraft(clientID, startDate); - - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "1000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "1000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); - ArrayList> transactions = (ArrayList>) transactionObj; - Integer dateCount = 0; - for (HashMap transaction : transactions) { - if (transaction.get("date").toString().equals("[2022, 7, 10]") && transaction.get("reversed").toString().equals("false")) { - dateCount++; - } - } - assertEquals(1, dateCount, "No Duplicate Overdraft Interest Posting"); + final Long savingsId = createActiveSavings(JULY_START_DATE, overdraftDailyPostingProduct()); + + withdraw(savingsId, "1000", JULY_START_DATE); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + withdraw(savingsId, "1000", JULY_START_DATE); + + assertEquals(1, countActiveTransactionsOn(savingsId, LocalDate.of(2022, Month.JULY, 10)), + "No Duplicate Overdraft Interest Posting"); } @Test public void testSavingsDailyInterestPostingJob() { - LocalDate today = Utils.getLocalDateOfTenant(); - try { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(true)); - BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, today); - final String startDate = "10 April 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); - - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); - ArrayList> transactions = (ArrayList>) transactionObj; - HashMap interestPostingTransaction = transactions.get(transactions.size() - 3); - for (Map.Entry entry : interestPostingTransaction.entrySet()) { - LOG.info("{} - {}", entry.getKey(), String.valueOf(entry.getValue())); - } - assertEquals("2.7405", interestPostingTransaction.get("amount").toString(), "Equality check for interest posted amount"); - assertEquals("[2022, 4, 12]", interestPostingTransaction.get("date").toString(), "Date check for Interest Posting transaction"); - List submittedOnDateStringList = (List) interestPostingTransaction.get("submittedOnDate"); - LocalDate submittedOnDate = submittedOnDateStringList.stream().collect( - Collectors.collectingAndThen(Collectors.toList(), list -> LocalDate.of(list.get(0), list.get(1), list.get(2)))); - assertTrue(DateUtils.isEqual(submittedOnDate, today), "Submitted On Date check for Interest Posting transaction"); - } finally { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(false)); - } + final LocalDate today = Utils.getLocalDateOfTenant(); + + businessDateHelper.runAt(today.toString(), () -> { + final Long savingsId = createActiveDailyPostingSavings(START_DATE); + deposit(savingsId, DEPOSIT_AMOUNT, START_DATE); + + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + final List transactions = savingsTransactionHelper.getTransactions(savingsId); + final SavingsAccountTransactionData interestPosting = transactions.get(transactions.size() - 3); + + SavingsTestValidators.verifyAmount(new BigDecimal("2.7405"), interestPosting.getAmount(), + "Equality check for interest posted amount"); + assertEquals(LocalDate.of(2022, Month.APRIL, 12), interestPosting.getDate(), "Date check for Interest Posting transaction"); + assertEquals(today, interestPosting.getSubmittedOnDate(), "Submitted On Date check for Interest Posting transaction"); + }); } @Test public void testSavingsDailyOverdraftInterestPostingJob() { - final String startDate = "10 April 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPostingOverdraft(clientID, startDate); - - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); - ArrayList> transactions = (ArrayList>) transactionObj; - HashMap interestPostingTransaction = transactions.get(transactions.size() - 2); - for (Map.Entry entry : interestPostingTransaction.entrySet()) { - LOG.info("{} - {}", entry.getKey(), String.valueOf(entry.getValue())); - } - assertEquals("2.7397", interestPostingTransaction.get("amount").toString(), "Equality check for overdatft interest posted amount"); - assertEquals("[2022, 4, 11]", interestPostingTransaction.get("date").toString(), + final Long savingsId = createActiveSavings(START_DATE, overdraftDailyPostingProduct()); + + withdraw(savingsId, DEPOSIT_AMOUNT, START_DATE); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + final List transactions = savingsTransactionHelper.getTransactions(savingsId); + final SavingsAccountTransactionData interestPosting = transactions.get(transactions.size() - 2); + + SavingsTestValidators.verifyAmount(new BigDecimal("2.7397"), interestPosting.getAmount(), + "Equality check for overdatft interest posted amount"); + assertEquals(LocalDate.of(2022, Month.APRIL, 11), interestPosting.getDate(), "Date check for overdraft Interest Posting transaction"); } @Test public void testAccountBalanceWithWithdrawalFeeAfterInterestPostingJob() { - final String startDate = "21 June 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPostingWithCharge(clientID, startDate); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "1000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - - this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "100", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - HashMap summary = this.savingsAccountHelper.getSavingsSummary(savingsId); - Float balance = Float.parseFloat("800.0"); - assertEquals(balance, summary.get("accountBalance"), "Verifying account balance is 800"); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); - ArrayList> transactions = (ArrayList>) transactionObj; - HashMap interestPostingTransaction = transactions.get(transactions.size() - 5); - for (Map.Entry entry : interestPostingTransaction.entrySet()) { - LOG.info("{} - {}", entry.getKey(), String.valueOf(entry.getValue())); - } - assertEquals("800.4384", interestPostingTransaction.get("runningBalance").toString(), "Equality check for Balance"); + final Long savingsId = createActiveDailyPostingSavingsWithWithdrawalFee(); + + deposit(savingsId, "1000", CHARGE_START_DATE); + withdraw(savingsId, "100", CHARGE_START_DATE); + SavingsTestValidators.verifyAmount(new BigDecimal("800.0"), savingsHelper.getSavingsSummary(savingsId).getAccountBalance(), + "Verifying account balance is 800"); + + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + final List transactions = savingsTransactionHelper.getTransactions(savingsId); + SavingsTestValidators.verifyAmount(new BigDecimal("800.4384"), transactions.get(transactions.size() - 5).getRunningBalance(), + "Equality check for Balance"); } @Test public void testRunningPostInterestJobTwiceDoesNotCreateDuplicateInterest() { - final LocalDate businessDate = LocalDate.of(2022, 4, 13); - try { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(true)); - BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, businessDate); + businessDateHelper.runAt("2022-04-13", () -> { + final Long savingsId = createActiveDailyPostingSavings(START_DATE); + deposit(savingsId, DEPOSIT_AMOUNT, START_DATE); - final String startDate = "10 April 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); + assertEquals(1, countActiveTransactionsOn(savingsId, LocalDate.of(2022, Month.APRIL, 12)), + "Running job twice must not create duplicate interest postings on the same date"); + }); + } - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); + @Test + public void testAccountBalanceUnchangedAfterRunningPostInterestJobTwice() { + businessDateHelper.runAt("2022-04-13", () -> { + final Long savingsId = createActiveDailyPostingSavings(START_DATE); + deposit(savingsId, DEPOSIT_AMOUNT, START_DATE); - Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); - ArrayList> transactions = (ArrayList>) transactionObj; + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + final BigDecimal balanceAfterFirstRun = savingsHelper.getSavingsSummary(savingsId).getAccountBalance(); - long interestPostingsCount = transactions.stream().filter(t -> t.get("date").toString().equals("[2022, 4, 12]")) - .filter(t -> t.get("reversed").toString().equals("false")).count(); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + final BigDecimal balanceAfterSecondRun = savingsHelper.getSavingsSummary(savingsId).getAccountBalance(); - assertEquals(1, interestPostingsCount, "Running job twice must not create duplicate interest postings on the same date"); - } finally { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(false)); - } + SavingsTestValidators.verifyAmount(balanceAfterFirstRun, balanceAfterSecondRun, + "Account balance must not change when job runs twice on the same business date"); + }); } - @Test - public void testAccountBalanceUnchangedAfterRunningPostInterestJobTwice() { - final LocalDate businessDate = LocalDate.of(2022, 4, 13); - try { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(true)); - BusinessDateHelper.updateBusinessDate(BusinessDateType.BUSINESS_DATE, businessDate); - - final String startDate = "10 April 2022"; - final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); - Assertions.assertNotNull(clientID); - - final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate); - this.savingsAccountHelper.depositToSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - HashMap summaryAfterFirstRun = this.savingsAccountHelper.getSavingsSummary(savingsId); - Float balanceAfterFirstRun = Float.parseFloat(summaryAfterFirstRun.get("accountBalance").toString()); - LOG.info("Balance after first run: {}", balanceAfterFirstRun); - - this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); - HashMap summaryAfterSecondRun = this.savingsAccountHelper.getSavingsSummary(savingsId); - Float balanceAfterSecondRun = Float.parseFloat(summaryAfterSecondRun.get("accountBalance").toString()); - LOG.info("Balance after second run: {}", balanceAfterSecondRun); - - assertEquals(balanceAfterFirstRun, balanceAfterSecondRun, 0.001f, - "Account balance must not change when job runs twice on the same business date"); - } finally { - globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, - new PutGlobalConfigurationsRequest().enabled(false)); - } + private long countActiveTransactionsOn(final Long savingsId, final LocalDate date) { + return savingsTransactionHelper.getTransactions(savingsId).stream().filter(t -> date.equals(t.getDate())) + .filter(t -> !Boolean.TRUE.equals(t.getReversed())).count(); } - private Integer createSavingsAccountDailyPosting(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - 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); - return savingsId; + private Long createActiveDailyPostingSavings(final String startDate) { + return createActiveSavings(startDate, dailyPostingProduct()); } - private Integer createSavingsAccountDailyPostingWithAccounting(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProduct("1000"); - 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 createActiveSavings(final String startDate, final Long productId) { + final Long clientId = createClient(startDate); + assertNotNull(clientId); + + final Long savingsId = submitSavingsApplication(clientId, productId, startDate).getSavingsId(); + assertNotNull(savingsId); + + approveSavings(savingsId, startDate); + SavingsTestValidators.verifySavingsIsApproved(savingsHelper.getSavingsStatus(savingsId)); + + activateSavings(savingsId, startDate); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId)); return savingsId; } - private Integer createSavingsAccountDailyPostingWithCharge(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPosting(); - Assertions.assertNotNull(savingsProductID); - final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientID, savingsProductID, - ACCOUNT_TYPE_INDIVIDUAL, startDate); - Assertions.assertNotNull(savingsId); - - final Integer withdrawalChargeId = ChargesHelper.createCharges(this.requestSpec, this.responseSpec, - ChargesHelper.getSavingsWithdrawalFeeJSON()); - Assertions.assertNotNull(withdrawalChargeId); - - this.savingsAccountHelper.addChargesForSavings(savingsId, withdrawalChargeId, false); - HashMap savingsStatusHashMap = this.savingsAccountHelper.approveSavingsOnDate(savingsId, startDate); - SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap); - savingsStatusHashMap = this.savingsAccountHelper.activateSavingsAccount(savingsId, startDate); - SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); + /** The charge has to be attached while the account is still pending. */ + private Long createActiveDailyPostingSavingsWithWithdrawalFee() { + final Long clientId = createClient(CHARGE_START_DATE); + assertNotNull(clientId); + + final Long savingsId = submitSavingsApplication(clientId, dailyPostingProduct(), CHARGE_START_DATE).getSavingsId(); + assertNotNull(savingsId); + + final Long withdrawalChargeId = savingsChargeHelper.createWithdrawalFeeCharge().getResourceId(); + assertNotNull(withdrawalChargeId); + savingsChargeHelper.addChargeToSavings(savingsId, withdrawalChargeId, SavingsTestData.DEFAULT_CHARGE_AMOUNT.floatValue()); + + approveSavings(savingsId, CHARGE_START_DATE); + SavingsTestValidators.verifySavingsIsApproved(savingsHelper.getSavingsStatus(savingsId)); + + activateSavings(savingsId, CHARGE_START_DATE); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(savingsId)); return savingsId; } - private Integer createSavingsAccountDailyPostingOverdraft(final Integer clientID, final String startDate) { - final Integer savingsProductID = createSavingsProductDailyPostingOverdraft(); - 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); - return savingsId; + private Long dailyPostingProduct() { + return createSavingsProduct(dailyPostingProductRequest()).getResourceId(); } - private Integer createSavingsProductDailyPosting() { - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + /** Monthly posting: the legacy builder never overrode the posting period, so it kept the monthly default. */ + private Long minimumOpeningBalanceProduct() { + return createSavingsProduct(dailyPostingProductRequest()// + .interestPostingPeriodType(SavingsTestData.InterestPostingPeriodType.MONTHLY)// + .minRequiredOpeningBalance(new BigDecimal("1000"))// + .accountingRule(SavingsTestData.AccountingRule.NONE)).getResourceId(); } - private Integer createSavingsProductDailyPostingOverdraft() { - final String overDraftLimit = "10000.0"; - final String nominalAnnualInterestRateOverdraft = "10"; - final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() - .withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance() - .withOverDraftRate(overDraftLimit, nominalAnnualInterestRateOverdraft).build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private Long overdraftDailyPostingProduct() { + return savingsProductHelper.createSavingsProduct(dailyPostingProductRequest()// + .allowOverdraft(true)// + .overdraftLimit(OVERDRAFT_LIMIT)// + .nominalAnnualInterestRateOverdraft(OVERDRAFT_INTEREST_RATE)).getResourceId(); } - public static Integer createSavingsProduct(final String minOpenningBalance) { - LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT ---------------------------------------"); - final String savingsProductJSON = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() - .withInterestCompoundingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance() - .withMinimumOpenningBalance(minOpenningBalance).withAccountingRuleAsNone().build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private PostSavingsProductsRequest dailyPostingProductRequest() { + return SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, + SavingsTestData.InterestPostingPeriodType.DAILY, SavingsTestData.InterestCalculationType.DAILY_BALANCE); } @AfterEach diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java index 763c0d61a09..6093da8d49b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java @@ -18,22 +18,20 @@ */ package org.apache.fineract.integrationtests; -import static org.apache.fineract.integrationtests.common.BusinessDateHelper.runAt; import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; -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.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; import java.time.Duration; import java.time.LocalDate; +import java.time.Month; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; @@ -43,101 +41,65 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.IntStream; -import org.apache.fineract.client.models.PostSavingsAccountsAccountIdRequest; -import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.integrationtests.common.ClientHelper; -import org.apache.fineract.integrationtests.common.CommonConstants; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +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.apache.fineract.integrationtests.common.ParallelExecutionHelper; -import org.apache.fineract.integrationtests.common.SchedulerJobHelper; import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.accounting.Account; -import org.apache.fineract.integrationtests.common.accounting.AccountHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsTestLifecycleExtension; -import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; 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; -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsInterestPostingTest { +public class SavingsInterestPostingTest extends FeignSavingsTestBase { private static final Logger LOG = LoggerFactory.getLogger(SavingsInterestPostingTest.class); - private static final int DUPLICATE_PREVENTION_ACCOUNT_COUNT = 50; - private static ResponseSpecification responseSpec; - private static RequestSpecification requestSpec; - private AccountHelper accountHelper; - private SavingsAccountHelper savingsAccountHelper; - private SchedulerJobHelper schedulerJobHelper; - public static final String MINIMUM_OPENING_BALANCE = "1000.0"; - private SavingsProductHelper productHelper; private static final String ACCRUALS_JOB_NAME = "Add Accrual Transactions For Savings"; private static final String POST_INTEREST_JOB_NAME = "Post Interest For Savings"; - private final Set createdSavingsAccountIds = Collections.synchronizedSet(new LinkedHashSet<>()); - @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.schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); - this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); - this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); - } + private static final int DUPLICATE_PREVENTION_ACCOUNT_COUNT = 50; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US); + private static final String CLIENT_ACTIVATION_DATE = "01 January 2025"; + + private static final Double INTEREST_RATE = 10.0; + private static final Double OVERDRAFT_INTEREST_RATE = 21.0; + private static final BigDecimal OVERDRAFT_LIMIT = new BigDecimal("100000"); + private static final BigDecimal PERCENT = new BigDecimal("100.00"); + private static final int DIGITS_AFTER_DECIMAL = 4; + + private final Set createdSavingsAccountIds = Collections.synchronizedSet(new LinkedHashSet<>()); @AfterEach public void cleanupAfterTest() { - cleanupSavingsAccountsFromDuplicatePreventionTest(); + closeTrackedSavingsAccounts(); } @Test public void testPostInterestWithOverdraftProduct() { - runAt("12 March 2025", () -> { - final String amount = "10000"; - - final Account assetAccount = accountHelper.createAssetAccount(); - final Account incomeAccount = accountHelper.createIncomeAccount(); - final Account expenseAccount = accountHelper.createExpenseAccount(); - final Account liabilityAccount = accountHelper.createLiabilityAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); - - final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( - interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), - interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); - - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); - final LocalDate startDate = LocalDate.of(2025, 2, 1); - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + businessDateHelper.runAt("2025-03-12", () -> { + final BigDecimal amount = new BigDecimal("10000"); + final LocalDate startDate = LocalDate.of(2025, Month.FEBRUARY, 1); + final LocalDate postingDate = LocalDate.of(2025, Month.MARCH, 2).minusDays(1); - final Integer accountId = createTrackedSavingsAccount(clientId, productId, startDateString); - savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); - savingsAccountHelper.activateSavings(accountId, startDateString); - savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + final Long accountId = createActiveOverdraftAccount(startDate); + deposit(accountId, amount.toPlainString(), DATE_FORMATTER.format(startDate)); - LocalDate marchDate = LocalDate.of(2025, 3, 2); + runAccrualAndPostInterestJobs(); - schedulerJobHelper.executeAndAwaitJob(ACCRUALS_JOB_NAME); - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + final long days = ChronoUnit.DAYS.between(startDate, postingDate); + final List interestTransactions = getInterestTransactions(accountId); + assertFalse(interestTransactions.isEmpty(), "No interest postings were found"); + SavingsTestValidators.verifyAmount(interestForPeriod(INTEREST_RATE, amount, days), interestTransactions.get(0).getAmount(), + "Verifying the posted interest"); - long days = ChronoUnit.DAYS.between(startDate, marchDate.minusDays(1)); - BigDecimal expected = calcInterestPosting(productHelper, amount, days); - - List txs = getInterestTransactions(accountId); - Assertions.assertEquals(expected, BigDecimal.valueOf(((Double) txs.get(0).get("amount"))), "ERROR in expected"); - - long interestCount = countInterestOnDate(accountId, marchDate.minusDays(1)); - long overdraftCount = countOverdraftOnDate(accountId, marchDate.minusDays(1)); - Assertions.assertEquals(1L, interestCount, "Expected exactly one INTEREST posting on posting date"); - Assertions.assertEquals(0L, overdraftCount, "Expected NO OVERDRAFT posting on posting date"); + assertEquals(1L, countInterestPostingsOn(accountId, postingDate), "Expected exactly one INTEREST posting on posting date"); + assertEquals(0L, countOverdraftPostingsOn(accountId, postingDate), "Expected NO OVERDRAFT posting on posting date"); assertNoAccrualReversals(accountId); }); @@ -145,48 +107,26 @@ public void testPostInterestWithOverdraftProduct() { @Test public void testOverdraftInterestWithOverdraftProduct() { - runAt("12 March 2025", () -> { - final String amount = "10000"; - - final Account assetAccount = accountHelper.createAssetAccount(); - final Account incomeAccount = accountHelper.createIncomeAccount(); - final Account expenseAccount = accountHelper.createExpenseAccount(); - final Account liabilityAccount = accountHelper.createLiabilityAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + businessDateHelper.runAt("2025-03-12", () -> { + final BigDecimal amount = new BigDecimal("10000"); + final LocalDate startDate = LocalDate.of(2025, Month.FEBRUARY, 1); + final LocalDate postingDate = LocalDate.of(2025, Month.MARCH, 2).minusDays(1); - final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( - interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), - interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + final Long accountId = createActiveOverdraftAccount(startDate); + withdraw(accountId, amount.toPlainString(), DATE_FORMATTER.format(startDate)); - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); - final LocalDate startDate = LocalDate.of(2025, 2, 1); - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + runAccrualAndPostInterestJobs(); - final Integer accountId = createTrackedSavingsAccount(clientId, productId, startDateString); - savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); - savingsAccountHelper.activateSavings(accountId, startDateString); - savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + final long days = ChronoUnit.DAYS.between(startDate, postingDate); + final List interestTransactions = getInterestTransactions(accountId); + assertFalse(interestTransactions.isEmpty(), "No interest postings were found"); + SavingsTestValidators.verifyAmount(interestForPeriod(OVERDRAFT_INTEREST_RATE, amount, days), + interestTransactions.get(0).getAmount(), "Verifying the posted overdraft interest"); - LocalDate marchDate = LocalDate.of(2025, 3, 2); + assertTrue(interestTransactions.get(0).getRunningBalance().signum() < 0, "Running balance is not less than zero"); - schedulerJobHelper.executeAndAwaitJob(ACCRUALS_JOB_NAME); - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - - long days = ChronoUnit.DAYS.between(startDate, marchDate.minusDays(1)); - BigDecimal expected = calcOverdraftPosting(productHelper, amount, days); - - List txs = getInterestTransactions(accountId); - Assertions.assertEquals(expected, BigDecimal.valueOf(((Double) txs.get(0).get("amount")))); - - BigDecimal runningBalance = BigDecimal.valueOf(((Double) txs.get(0).get("runningBalance"))); - Assertions.assertTrue(MathUtil.isLessThanZero(runningBalance), "Running balance is not less than zero"); - - long interestCount = countInterestOnDate(accountId, marchDate.minusDays(1)); - long overdraftCount = countOverdraftOnDate(accountId, marchDate.minusDays(1)); - Assertions.assertEquals(0L, interestCount, "Expected NO INTEREST posting on posting date"); - Assertions.assertEquals(1L, overdraftCount, "Expected exactly one OVERDRAFT posting on posting date"); + assertEquals(0L, countInterestPostingsOn(accountId, postingDate), "Expected NO INTEREST posting on posting date"); + assertEquals(1L, countOverdraftPostingsOn(accountId, postingDate), "Expected exactly one OVERDRAFT posting on posting date"); assertNoAccrualReversals(accountId); }); @@ -194,64 +134,32 @@ public void testOverdraftInterestWithOverdraftProduct() { @Test public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceLessZero() { - runAt("12 March 2025", () -> { - final String amountDeposit = "10000"; - final String amountWithdrawal = "20000"; - - final Account assetAccount = accountHelper.createAssetAccount(); - final Account incomeAccount = accountHelper.createIncomeAccount(); - final Account expenseAccount = accountHelper.createExpenseAccount(); - final Account liabilityAccount = accountHelper.createLiabilityAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); - - final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( - interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), - interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); - - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); - final LocalDate startDate = LocalDate.of(2025, 2, 1); - final String startStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); - - final Integer accountId = createTrackedSavingsAccount(clientId, productId, startStr); - savingsAccountHelper.approveSavingsOnDate(accountId, startStr); - savingsAccountHelper.activateSavings(accountId, startStr); - savingsAccountHelper.depositToSavingsAccount(accountId, amountDeposit, startStr, CommonConstants.RESPONSE_RESOURCE_ID); - - final LocalDate withdrawalDate = LocalDate.of(2025, 2, 16); - final String withdrawalStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(withdrawalDate); - savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amountWithdrawal, withdrawalStr, - CommonConstants.RESPONSE_RESOURCE_ID); - - LocalDate marchDate = LocalDate.of(2025, 3, 2); - - schedulerJobHelper.executeAndAwaitJob(ACCRUALS_JOB_NAME); - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - - List txs = getInterestTransactions(accountId); - for (HashMap tx : txs) { - BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount"))); - @SuppressWarnings("unchecked") - Map typeMap = (Map) tx.get("transactionType"); - SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); - - if (type.isInterestPosting()) { - long days = ChronoUnit.DAYS.between(startDate, withdrawalDate); - BigDecimal expected = calcInterestPosting(productHelper, amountDeposit, days); - Assertions.assertEquals(expected, amt); + businessDateHelper.runAt("2025-03-12", () -> { + final BigDecimal amountDeposit = new BigDecimal("10000"); + final BigDecimal amountWithdrawal = new BigDecimal("20000"); + final LocalDate startDate = LocalDate.of(2025, Month.FEBRUARY, 1); + final LocalDate withdrawalDate = LocalDate.of(2025, Month.FEBRUARY, 16); + final LocalDate postingDate = LocalDate.of(2025, Month.MARCH, 2).minusDays(1); + + final Long accountId = createActiveOverdraftAccount(startDate); + deposit(accountId, amountDeposit.toPlainString(), DATE_FORMATTER.format(startDate)); + withdraw(accountId, amountWithdrawal.toPlainString(), DATE_FORMATTER.format(withdrawalDate)); + + runAccrualAndPostInterestJobs(); + + for (SavingsAccountTransactionData transaction : getInterestTransactions(accountId)) { + final BigDecimal expected; + if (Boolean.TRUE.equals(transaction.getTransactionType().getInterestPosting())) { + expected = interestForPeriod(INTEREST_RATE, amountDeposit, ChronoUnit.DAYS.between(startDate, withdrawalDate)); } else { - long days = ChronoUnit.DAYS.between(withdrawalDate, marchDate.minusDays(1)); - BigDecimal overdraftBase = new BigDecimal(amountWithdrawal).subtract(new BigDecimal(amountDeposit)); - BigDecimal expected = calcOverdraftPosting(productHelper, overdraftBase.toString(), days); - Assertions.assertEquals(expected, amt); + expected = interestForPeriod(OVERDRAFT_INTEREST_RATE, amountWithdrawal.subtract(amountDeposit), + ChronoUnit.DAYS.between(withdrawalDate, postingDate)); } + SavingsTestValidators.verifyAmount(expected, transaction.getAmount(), "Verifying the posting of " + transaction.getDate()); } - Assertions.assertEquals(1L, countInterestOnDate(accountId, marchDate.minusDays(1)), - "Expected exactly one INTEREST posting on posting date"); - Assertions.assertEquals(1L, countOverdraftOnDate(accountId, marchDate.minusDays(1)), - "Expected exactly one OVERDRAFT posting on posting date"); + assertEquals(1L, countInterestPostingsOn(accountId, postingDate), "Expected exactly one INTEREST posting on posting date"); + assertEquals(1L, countOverdraftPostingsOn(accountId, postingDate), "Expected exactly one OVERDRAFT posting on posting date"); assertNoAccrualReversals(accountId); }); @@ -259,63 +167,33 @@ public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceLess @Test public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceGreaterZero() { - runAt("12 March 2025", () -> { - final String amountDeposit = "20000"; - final String amountWithdrawal = "10000"; - - final Account assetAccount = accountHelper.createAssetAccount(); - final Account incomeAccount = accountHelper.createIncomeAccount(); - final Account expenseAccount = accountHelper.createExpenseAccount(); - final Account liabilityAccount = accountHelper.createLiabilityAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); - - final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( - interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), - interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); - - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); - final LocalDate startDate = LocalDate.of(2025, 2, 1); - final String startStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); - - final Integer accountId = createTrackedSavingsAccount(clientId, productId, startStr); - savingsAccountHelper.approveSavingsOnDate(accountId, startStr); - savingsAccountHelper.activateSavings(accountId, startStr); - savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amountWithdrawal, startStr, CommonConstants.RESPONSE_RESOURCE_ID); - - final LocalDate depositDate = LocalDate.of(2025, 2, 16); - final String depositStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(depositDate); - savingsAccountHelper.depositToSavingsAccount(accountId, amountDeposit, depositStr, CommonConstants.RESPONSE_RESOURCE_ID); - - LocalDate marchDate = LocalDate.of(2025, 3, 2); - - schedulerJobHelper.executeAndAwaitJob(ACCRUALS_JOB_NAME); - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - - List txs = getInterestTransactions(accountId); - for (HashMap tx : txs) { - BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount"))); - @SuppressWarnings("unchecked") - Map typeMap = (Map) tx.get("transactionType"); - SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); - - if (type.isOverDraftInterestPosting()) { - long days = ChronoUnit.DAYS.between(startDate, depositDate); - BigDecimal expected = calcOverdraftPosting(productHelper, amountWithdrawal, days); - Assertions.assertEquals(expected, amt); + businessDateHelper.runAt("2025-03-12", () -> { + final BigDecimal amountDeposit = new BigDecimal("20000"); + final BigDecimal amountWithdrawal = new BigDecimal("10000"); + final LocalDate startDate = LocalDate.of(2025, Month.FEBRUARY, 1); + final LocalDate depositDate = LocalDate.of(2025, Month.FEBRUARY, 16); + final LocalDate postingDate = LocalDate.of(2025, Month.MARCH, 2).minusDays(1); + + final Long accountId = createActiveOverdraftAccount(startDate); + withdraw(accountId, amountWithdrawal.toPlainString(), DATE_FORMATTER.format(startDate)); + deposit(accountId, amountDeposit.toPlainString(), DATE_FORMATTER.format(depositDate)); + + runAccrualAndPostInterestJobs(); + + for (SavingsAccountTransactionData transaction : getInterestTransactions(accountId)) { + final BigDecimal expected; + if (Boolean.TRUE.equals(transaction.getTransactionType().getOverDraftInterestPosting())) { + expected = interestForPeriod(OVERDRAFT_INTEREST_RATE, amountWithdrawal, + ChronoUnit.DAYS.between(startDate, depositDate)); } else { - long days = ChronoUnit.DAYS.between(depositDate, marchDate.minusDays(1)); - BigDecimal positiveBase = new BigDecimal(amountDeposit).subtract(new BigDecimal(amountWithdrawal)); - BigDecimal expected = calcInterestPosting(productHelper, positiveBase.toString(), days); - Assertions.assertEquals(expected, amt); + expected = interestForPeriod(INTEREST_RATE, amountDeposit.subtract(amountWithdrawal), + ChronoUnit.DAYS.between(depositDate, postingDate)); } + SavingsTestValidators.verifyAmount(expected, transaction.getAmount(), "Verifying the posting of " + transaction.getDate()); } - Assertions.assertEquals(1L, countOverdraftOnDate(accountId, marchDate.minusDays(1)), - "Expected exactly one OVERDRAFT posting on posting date"); - Assertions.assertEquals(1L, countInterestOnDate(accountId, marchDate.minusDays(1)), - "Expected exactly one INTEREST posting on posting date"); + assertEquals(1L, countOverdraftPostingsOn(accountId, postingDate), "Expected exactly one OVERDRAFT posting on posting date"); + assertEquals(1L, countInterestPostingsOn(accountId, postingDate), "Expected exactly one INTEREST posting on posting date"); assertNoAccrualReversals(accountId); }); @@ -323,77 +201,44 @@ public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceGrea @Test public void testPostInterestNotZero() { - runAt("12 March 2025", () -> { - final String amountDeposit = "1000"; - final String amountWithdrawal = "1000"; - - final Account assetAccount = accountHelper.createAssetAccount(); - final Account incomeAccount = accountHelper.createIncomeAccount(); - final Account expenseAccount = accountHelper.createExpenseAccount(); - final Account liabilityAccount = accountHelper.createLiabilityAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); - - final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( - interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), - interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); - - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); - final LocalDate startDate = LocalDate.of(2025, 1, 1); - final String startStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); - - final Integer accountId = createTrackedSavingsAccount(clientId, productId, startStr); - savingsAccountHelper.approveSavingsOnDate(accountId, startStr); - savingsAccountHelper.activateSavings(accountId, startStr); - savingsAccountHelper.depositToSavingsAccount(accountId, amountDeposit, startStr, CommonConstants.RESPONSE_RESOURCE_ID); - - LocalDate februaryDate = LocalDate.of(2025, 2, 1); - - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - - List txsFebruary = getInterestTransactions(accountId); - - long daysFebruary = ChronoUnit.DAYS.between(startDate, februaryDate); - BigDecimal expectedFebruary = calcInterestPosting(productHelper, amountDeposit, daysFebruary); - Assertions.assertEquals(expectedFebruary, BigDecimal.valueOf(((Double) txsFebruary.get(0).get("amount")))); - - final LocalDate withdrawalDate = LocalDate.of(2025, 2, 1); - final String withdrawal = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(withdrawalDate); - - BigDecimal runningBalance = new BigDecimal(txsFebruary.get(0).get("runningBalance").toString()); - String withdrawalRunning = runningBalance.setScale(2, RoundingMode.HALF_UP).toString(); - - savingsAccountHelper.withdrawalFromSavingsAccount(accountId, withdrawalRunning, withdrawal, - CommonConstants.RESPONSE_RESOURCE_ID); - savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amountWithdrawal, withdrawal, - CommonConstants.RESPONSE_RESOURCE_ID); - - LocalDate marchDate = LocalDate.of(2025, 3, 1); - - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - - List txs = getInterestTransactions(accountId); - - for (HashMap tx : txs) { - BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount"))); - @SuppressWarnings("unchecked") - Map typeMap = (Map) tx.get("transactionType"); - SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); - if (type.isOverDraftInterestPosting()) { - long days = ChronoUnit.DAYS.between(withdrawalDate, marchDate); - BigDecimal decimalsss = new BigDecimal(txsFebruary.get(0).get("runningBalance").toString()) - .subtract(runningBalance.setScale(2, RoundingMode.HALF_UP)); - BigDecimal withdraw = new BigDecimal(amountWithdrawal); - BigDecimal res = withdraw.subtract(decimalsss); - BigDecimal expected = calcOverdraftPosting(productHelper, res.toString(), days); - Assertions.assertEquals(expected, amt); + businessDateHelper.runAt("2025-03-12", () -> { + final BigDecimal amountDeposit = new BigDecimal("1000"); + final BigDecimal amountWithdrawal = new BigDecimal("1000"); + final LocalDate startDate = LocalDate.of(2025, Month.JANUARY, 1); + final LocalDate februaryDate = LocalDate.of(2025, Month.FEBRUARY, 1); + final LocalDate marchDate = LocalDate.of(2025, Month.MARCH, 1); + + final Long accountId = createActiveOverdraftAccount(startDate); + deposit(accountId, amountDeposit.toPlainString(), DATE_FORMATTER.format(startDate)); + + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + final List februaryPostings = getInterestTransactions(accountId); + assertFalse(februaryPostings.isEmpty(), "No interest postings were found for February"); + SavingsTestValidators.verifyAmount( + interestForPeriod(INTEREST_RATE, amountDeposit, ChronoUnit.DAYS.between(startDate, februaryDate)), + februaryPostings.get(0).getAmount(), "Verifying the February posting"); + + // withdrawing the running balance leaves the account at zero, so the next withdrawal opens the overdraft + final BigDecimal runningBalance = februaryPostings.get(0).getRunningBalance(); + final BigDecimal roundedRunningBalance = runningBalance.setScale(2, RoundingMode.HALF_UP); + final String withdrawalDateString = DATE_FORMATTER.format(februaryDate); + withdraw(accountId, roundedRunningBalance.toPlainString(), withdrawalDateString); + withdraw(accountId, amountWithdrawal.toPlainString(), withdrawalDateString); + + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + + final BigDecimal overdrawnBalance = amountWithdrawal.subtract(runningBalance.subtract(roundedRunningBalance)); + for (SavingsAccountTransactionData transaction : getInterestTransactions(accountId)) { + if (Boolean.TRUE.equals(transaction.getTransactionType().getOverDraftInterestPosting())) { + SavingsTestValidators.verifyAmount( + interestForPeriod(OVERDRAFT_INTEREST_RATE, overdrawnBalance, ChronoUnit.DAYS.between(februaryDate, marchDate)), + transaction.getAmount(), "Verifying the overdraft posting of " + transaction.getDate()); } } - Assertions.assertEquals(0L, countInterestOnDate(accountId, marchDate), "Expected exactly one INTEREST posting on posting date"); - Assertions.assertEquals(1L, countOverdraftOnDate(accountId, marchDate), - "Expected exactly one OVERDRAFT posting on posting date"); + assertEquals(0L, countInterestPostingsOn(accountId, marchDate), "Expected NO INTEREST posting on posting date"); + assertEquals(1L, countOverdraftPostingsOn(accountId, marchDate), "Expected exactly one OVERDRAFT posting on posting date"); assertNoAccrualReversals(accountId); }); @@ -401,286 +246,202 @@ public void testPostInterestNotZero() { @Test public void testPostInterestForDuplicatePrevention() { - runAt("18 March 2025", () -> { + businessDateHelper.runAt("2025-03-18", () -> { final String amount = "10000"; + final LocalDate startDate = LocalDate.of(2025, Month.FEBRUARY, 1); + final String startDateString = DATE_FORMATTER.format(startDate); - final Account assetAccount = accountHelper.createAssetAccount(); - final Account incomeAccount = accountHelper.createIncomeAccount(); - final Account expenseAccount = accountHelper.createExpenseAccount(); - final Account liabilityAccount = accountHelper.createLiabilityAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); - final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + final Long productId = createOverdraftAccrualProduct(); - final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( - interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), - interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); - - final LocalDate startDate = LocalDate.of(2025, 2, 1); - - final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); - List accountIdList = new CopyOnWriteArrayList<>(); + final List accountIdList = new CopyOnWriteArrayList<>(); ParallelExecutionHelper.runInParallel(IntStream.range(0, DUPLICATE_PREVENTION_ACCOUNT_COUNT).boxed().toList(), (i) -> { - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); - final Integer accountId = createTrackedSavingsAccount(clientId, productId, startDateString); + final Long clientId = createClient(CLIENT_ACTIVATION_DATE); + final Long accountId = createTrackedSavingsAccount(clientId, productId, startDateString); - savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); - savingsAccountHelper.activateSavings(accountId, startDateString); - savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + approveSavings(accountId, startDateString); + activateSavings(accountId, startDateString); + deposit(accountId, amount, startDateString); accountIdList.add(accountId); }); - Assertions.assertEquals(DUPLICATE_PREVENTION_ACCOUNT_COUNT, accountIdList.size(), - "ERROR: Expected " + DUPLICATE_PREVENTION_ACCOUNT_COUNT); + assertEquals(DUPLICATE_PREVENTION_ACCOUNT_COUNT, accountIdList.size(), "ERROR: Expected " + DUPLICATE_PREVENTION_ACCOUNT_COUNT); - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(2)).untilAsserted(() -> { - ParallelExecutionHelper.runInParallel(accountIdList, (accountId) -> { - List txs = getInterestTransactions(accountId); - Assertions.assertEquals(1, txs.size(), "ERROR: Duplicate interest postings exist for account " + accountId); - }); + ParallelExecutionHelper.runInParallel(accountIdList, (accountId) -> assertEquals(1, + getInterestTransactions(accountId).size(), "ERROR: Duplicate interest postings exist for account " + accountId)); }); }); } @Test public void testNoDuplicateInterestPostingWhenMixingManualAndJobRunsAcrossBusinessDates() { - final Integer[] savingsAccountId = new Integer[1]; - final int[] interestTxAfterFirstManualPost = new int[1]; - final int[] interestTxAfterManualAsOnPost = new int[1]; final String accountOpeningDate = "01 January 2025"; final String firstManualPostingDate = "02 February 2025"; final String manualAsOnPostingDate = "15 March 2025"; - final String schedulerPostingDate = "02 April 2025"; final String depositAmount = "10000"; - runAt(accountOpeningDate, () -> { - final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, accountOpeningDate); - - final String savingsProductJSON = new SavingsProductHelper() // - .withInterestCompoundingPeriodTypeAsAnnually() // - .withInterestPostingPeriodTypeAsMonthly() // - .withInterestCalculationPeriodTypeAsDailyBalance() // - .build(); - final Integer productId = SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); - - final Integer accountId = createTrackedSavingsAccount(clientId, productId, accountOpeningDate); + final Long[] savingsAccountId = new Long[1]; + final int[] interestTxAfterFirstManualPost = new int[1]; + final int[] interestTxAfterManualAsOnPost = new int[1]; - savingsAccountHelper.approveSavingsOnDate(accountId, accountOpeningDate); - savingsAccountHelper.activateSavings(accountId, accountOpeningDate); - savingsAccountHelper.depositToSavingsAccount(accountId, depositAmount, accountOpeningDate, - CommonConstants.RESPONSE_RESOURCE_ID); + businessDateHelper.runAt("2025-01-01", () -> { + final Long clientId = createClient(accountOpeningDate); + final Long productId = createSavingsProduct( + SavingsRequestBuilders.savingsProduct(SavingsTestData.InterestCompoundingPeriodType.ANNUAL, + SavingsTestData.InterestPostingPeriodType.MONTHLY, SavingsTestData.InterestCalculationType.DAILY_BALANCE)) + .getResourceId(); + + final Long accountId = createTrackedSavingsAccount(clientId, productId, accountOpeningDate); + approveSavings(accountId, accountOpeningDate); + activateSavings(accountId, accountOpeningDate); + deposit(accountId, depositAmount, accountOpeningDate); savingsAccountId[0] = accountId; }); - runAt(firstManualPostingDate, () -> { - savingsAccountHelper.postInterestForSavings(savingsAccountId[0]); + businessDateHelper.runAt("2025-02-02", () -> { + savingsHelper.postInterest(savingsAccountId[0]); interestTxAfterFirstManualPost[0] = getActiveInterestTransactions(savingsAccountId[0]).size(); - Assertions.assertTrue(interestTxAfterFirstManualPost[0] > 0, "Expected interest transactions after first manual posting"); + assertTrue(interestTxAfterFirstManualPost[0] > 0, "Expected interest transactions after first manual posting"); }); - runAt(manualAsOnPostingDate, () -> { - savingsAccountHelper.postInterestAsOnSavings(savingsAccountId[0], manualAsOnPostingDate); + businessDateHelper.runAt("2025-03-15", () -> { + savingsTransactionHelper.postInterestAsOn(savingsAccountId[0], manualAsOnPostingDate); interestTxAfterManualAsOnPost[0] = getActiveInterestTransactions(savingsAccountId[0]).size(); - Assertions.assertTrue(interestTxAfterManualAsOnPost[0] > interestTxAfterFirstManualPost[0], + assertTrue(interestTxAfterManualAsOnPost[0] > interestTxAfterFirstManualPost[0], "Expected additional interest transaction(s) after manual post-as-on execution"); }); - runAt(schedulerPostingDate, () -> { - schedulerJobHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); + businessDateHelper.runAt("2025-04-02", () -> { + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); - List activeInterestTransactions = getActiveInterestTransactions(savingsAccountId[0]); - Assertions.assertTrue(activeInterestTransactions.size() >= interestTxAfterManualAsOnPost[0], + final List activeInterestTransactions = getActiveInterestTransactions(savingsAccountId[0]); + assertTrue(activeInterestTransactions.size() >= interestTxAfterManualAsOnPost[0], "Scheduler run should not reduce number of posted interest transactions"); - Map interestTransactionCountByDate = new HashMap<>(); - for (HashMap tx : activeInterestTransactions) { - LocalDate transactionDate = coerceToLocalDate(tx); - Assertions.assertNotNull(transactionDate, "Could not determine date of an interest transaction"); - interestTransactionCountByDate.merge(transactionDate, 1, Integer::sum); + final Map interestTransactionCountByDate = new HashMap<>(); + for (SavingsAccountTransactionData transaction : activeInterestTransactions) { + assertNotNull(transaction.getDate(), "Could not determine date of an interest transaction"); + interestTransactionCountByDate.merge(transaction.getDate(), 1, Integer::sum); } interestTransactionCountByDate - .forEach((txDate, txCount) -> Assertions.assertEquals(1, txCount, "Multiple interest postings found on " + txDate)); + .forEach((txDate, txCount) -> assertEquals(1, txCount, "Multiple interest postings found on " + txDate)); }); } - private void cleanupSavingsAccountsFromDuplicatePreventionTest() { - try { - LOG.info("Starting cleanup of savings accounts after duplicate prevention test"); - - if (!createdSavingsAccountIds.isEmpty()) { - LOG.info("Found {} tracked savings accounts to cleanup", createdSavingsAccountIds.size()); - - createdSavingsAccountIds.forEach(savingsId -> { - try { - - savingsAccountHelper.postInterestForSavings(savingsId.intValue()); - - savingsAccountHelper.closeSavingsAccount(savingsId, - new PostSavingsAccountsAccountIdRequest().locale("en").dateFormat(Utils.DATE_FORMAT) - .closedOnDate(Utils.dateFormatter.format(Utils.getLocalDateOfTenant())).withdrawBalance(true)); - - LOG.debug("Savings account {} closed successfully", savingsId); - } catch (Exception e) { - LOG.warn("Unable to close savings account {}: {}", savingsId, e.getMessage()); - } - }); - createdSavingsAccountIds.clear(); - - LOG.info("Savings accounts cleanup completed"); - } else { - LOG.info("No tracked savings accounts found to cleanup"); - } - } catch (Exception e) { - LOG.error("Error during savings accounts cleanup: {}", e.getMessage(), e); - } + /** Ordered the way the server does it: the annual rate becomes a daily one before it meets the balance. */ + private BigDecimal interestForPeriod(final Double annualRate, final BigDecimal balance, final long days) { + final BigDecimal rate = BigDecimal.valueOf(annualRate).divide(PERCENT); + final BigDecimal dayFactor = BigDecimal.ONE.divide(BigDecimal.valueOf(SavingsTestData.InterestCalculationDaysInYearType.DAYS_365), + MathContext.DECIMAL64); + final BigDecimal dailyRate = rate.multiply(dayFactor, MathContext.DECIMAL64); + final BigDecimal periodRate = dailyRate.multiply(BigDecimal.valueOf(days), MathContext.DECIMAL64); + return balance.multiply(periodRate, MathContext.DECIMAL64).setScale(DIGITS_AFTER_DECIMAL, RoundingMode.HALF_EVEN); } - private Integer createTrackedSavingsAccount(Integer clientId, Integer productId, String submittedOnDate) { - Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, - SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, submittedOnDate); - createdSavingsAccountIds.add(accountId.longValue()); - return accountId; + private void runAccrualAndPostInterestJobs() { + schedulerHelper.executeAndAwaitJob(ACCRUALS_JOB_NAME); + schedulerHelper.executeAndAwaitJob(POST_INTEREST_JOB_NAME); } - private List getInterestTransactions(Integer savingsAccountId) { - List all = savingsAccountHelper.getSavingsTransactions(savingsAccountId); - List filtered = new ArrayList<>(); - for (HashMap tx : all) { - @SuppressWarnings("unchecked") - Map txType = (Map) tx.get("transactionType"); - SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) txType.get("id")).intValue()); - if (type.isInterestPosting() || type.isOverDraftInterestPosting()) { - filtered.add(tx); - } - } - return filtered; + private List getInterestTransactions(final Long savingsAccountId) { + return savingsTransactionHelper.getTransactions(savingsAccountId).stream().filter(SavingsInterestPostingTest::isInterestPosting) + .toList(); } - private List getActiveInterestTransactions(Integer savingsAccountId) { - List activeInterestTransactions = new ArrayList<>(); - for (HashMap tx : getInterestTransactions(savingsAccountId)) { - if (!isReversed(tx)) { - activeInterestTransactions.add(tx); - } - } - return activeInterestTransactions; + private List getActiveInterestTransactions(final Long savingsAccountId) { + return getInterestTransactions(savingsAccountId).stream().filter(transaction -> !Boolean.TRUE.equals(transaction.getReversed())) + .toList(); } - public Integer createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(final String interestPayableAccount, - final String savingsControlAccount, final String interestReceivableAccount, final Account... accounts) { - LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT WITHOUT OVERDRAFT ---------------------------------------"); - this.productHelper = new SavingsProductHelper().withOverDraftRate("100000", "21") - .withAccountInterestReceivables(interestReceivableAccount).withSavingsControlAccountId(savingsControlAccount) - .withInterestPayableAccountId(interestPayableAccount).withInterestCompoundingPeriodTypeAsAnnually() - .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() - .withAccountingRuleAsAccrualBased(accounts); - - final String savingsProductJSON = this.productHelper.build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private static boolean isInterestPosting(final SavingsAccountTransactionData transaction) { + return transaction.getTransactionType() != null && (Boolean.TRUE.equals(transaction.getTransactionType().getInterestPosting()) + || Boolean.TRUE.equals(transaction.getTransactionType().getOverDraftInterestPosting())); } - private BigDecimal calcInterestPosting(SavingsProductHelper productHelper, String amount, long days) { - BigDecimal rate = productHelper.getNominalAnnualInterestRate().divide(new BigDecimal("100.00")); - BigDecimal principal = new BigDecimal(amount); - BigDecimal dayFactor = BigDecimal.ONE.divide(productHelper.getInterestCalculationDaysInYearType(), MathContext.DECIMAL64); - BigDecimal dailyRate = rate.multiply(dayFactor, MathContext.DECIMAL64); - BigDecimal periodRate = dailyRate.multiply(BigDecimal.valueOf(days), MathContext.DECIMAL64); - return principal.multiply(periodRate, MathContext.DECIMAL64).setScale(productHelper.getDecimalCurrency(), RoundingMode.HALF_EVEN); + private long countInterestPostingsOn(final Long savingsAccountId, final LocalDate date) { + return savingsTransactionHelper.getTransactions(savingsAccountId).stream().filter(t -> date.equals(t.getDate())) + .filter(t -> t.getTransactionType() != null && Boolean.TRUE.equals(t.getTransactionType().getInterestPosting())).count(); } - private BigDecimal calcOverdraftPosting(SavingsProductHelper productHelper, String amount, long days) { - BigDecimal rate = productHelper.getNominalAnnualInterestRateOverdraft().divide(new BigDecimal("100.00")); - BigDecimal principal = new BigDecimal(amount); - BigDecimal dayFactor = BigDecimal.ONE.divide(productHelper.getInterestCalculationDaysInYearType(), MathContext.DECIMAL64); - BigDecimal dailyRate = rate.multiply(dayFactor, MathContext.DECIMAL64); - BigDecimal periodRate = dailyRate.multiply(BigDecimal.valueOf(days), MathContext.DECIMAL64); - return principal.multiply(periodRate, MathContext.DECIMAL64).setScale(productHelper.getDecimalCurrency(), RoundingMode.HALF_EVEN); + private long countOverdraftPostingsOn(final Long savingsAccountId, final LocalDate date) { + return savingsTransactionHelper.getTransactions(savingsAccountId).stream().filter(t -> date.equals(t.getDate())) + .filter(t -> t.getTransactionType() != null && Boolean.TRUE.equals(t.getTransactionType().getOverDraftInterestPosting())) + .count(); } - @SuppressWarnings("unchecked") - private LocalDate coerceToLocalDate(HashMap tx) { - String[] candidateKeys = new String[] { "date", "transactionDate", "submittedOnDate", "createdDate" }; - - for (String key : candidateKeys) { - Object v = tx.get(key); - if (v == null) { - continue; - } - - if (v instanceof List) { - List arr = (List) v; - if (arr.size() >= 3 && arr.get(0) instanceof Number && arr.get(1) instanceof Number && arr.get(2) instanceof Number) { - int year = ((Number) arr.get(0)).intValue(); - int month = ((Number) arr.get(1)).intValue(); - int day = ((Number) arr.get(2)).intValue(); - return LocalDate.of(year, month, day); - } - } - - if (v instanceof String) { - String s = (String) v; - DateTimeFormatter[] fmts = new DateTimeFormatter[] { DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US), - DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.US), DateTimeFormatter.ofPattern("yyyy-MM-dd") }; - for (DateTimeFormatter f : fmts) { - try { - return LocalDate.parse(s, f); - } catch (Exception ignore) { - // intentionally ignored - } - } - } - } - return null; + private void assertNoAccrualReversals(final Long savingsAccountId) { + final long reversedAccruals = savingsTransactionHelper.getAccrualTransactions(savingsAccountId).stream() + .filter(transaction -> Boolean.TRUE.equals(transaction.getReversed())).count(); + assertEquals(0L, reversedAccruals, "Accrual reversals were found in account transactions"); } - private boolean isDate(HashMap tx, LocalDate expected) { - LocalDate got = coerceToLocalDate(tx); - return got != null && got.isEqual(expected); - } + private Long createActiveOverdraftAccount(final LocalDate startDate) { + final Long clientId = createClient(CLIENT_ACTIVATION_DATE); + assertNotNull(clientId); - @SuppressWarnings("unchecked") - private SavingsAccountTransactionType txType(HashMap tx) { - Map typeMap = (Map) tx.get("transactionType"); - return SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); - } + final Long productId = createOverdraftAccrualProduct(); + final String startDateString = DATE_FORMATTER.format(startDate); + final Long accountId = createTrackedSavingsAccount(clientId, productId, startDateString); - private long countInterestOnDate(Integer accountId, LocalDate date) { - List all = savingsAccountHelper.getSavingsTransactions(accountId); - return all.stream().filter(tx -> isDate(tx, date)).map(this::txType).filter(SavingsAccountTransactionType::isInterestPosting) - .count(); + approveSavings(accountId, startDateString); + activateSavings(accountId, startDateString); + SavingsTestValidators.verifySavingsIsActive(savingsHelper.getSavingsStatus(accountId)); + return accountId; } - private long countOverdraftOnDate(Integer accountId, LocalDate date) { - List all = savingsAccountHelper.getSavingsTransactions(accountId); - return all.stream().filter(tx -> isDate(tx, date)).map(this::txType) - .filter(SavingsAccountTransactionType::isOverDraftInterestPosting).count(); + private Long createOverdraftAccrualProduct() { + final Account assetAccount = accountHelper.createAssetAccount("assetAccount"); + final Account incomeAccount = accountHelper.createIncomeAccount("incomeAccount"); + final Account expenseAccount = accountHelper.createExpenseAccount("expenseAccount"); + final Account liabilityAccount = accountHelper.createLiabilityAccount("liabilityAccount"); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final PostSavingsProductsRequest request = SavingsRequestBuilders + .savingsProduct(SavingsTestData.InterestCompoundingPeriodType.ANNUAL, SavingsTestData.InterestPostingPeriodType.MONTHLY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE) + .nominalAnnualInterestRate(INTEREST_RATE)// + .allowOverdraft(true)// + .accountingRule(SavingsTestData.AccountingRule.ACCRUAL_PERIODIC); + + final PostSavingsProductsRequest product = SavingsRequestBuilders + .withAccrualAccountingMappings(request, assetAccount, liabilityAccount, incomeAccount, expenseAccount, + interestReceivableAccount) + .savingsControlAccountId(SavingsRequestBuilders.accountId(savingsControlAccount))// + .interestPayableAccountId(SavingsRequestBuilders.accountId(interestPayableAccount))// + .overdraftLimit(OVERDRAFT_LIMIT)// + .nominalAnnualInterestRateOverdraft(BigDecimal.valueOf(OVERDRAFT_INTEREST_RATE)); + + final Long productId = savingsProductHelper.createSavingsProduct(product).getResourceId(); + assertNotNull(productId, "Error creating savings product."); + return productId; } - @SuppressWarnings({ "rawtypes" }) - private boolean isReversed(HashMap tx) { - Object v = tx.get("reversed"); - if (v instanceof Boolean) { - return (Boolean) v; - } - if (v instanceof Number) { - return ((Number) v).intValue() != 0; - } - if (v instanceof String) { - return Boolean.parseBoolean((String) v); - } - return false; + private Long createTrackedSavingsAccount(final Long clientId, final Long productId, final String submittedOnDate) { + final Long accountId = submitSavingsApplication(clientId, productId, submittedOnDate).getSavingsId(); + assertNotNull(accountId, "Error applying for savings account."); + createdSavingsAccountIds.add(accountId); + return accountId; } - @SuppressWarnings("rawtypes") - private void assertNoAccrualReversals(Integer accountId) { - List all = savingsAccountHelper.getSavingsTransactions(accountId); - long reversedAccruals = all.stream().filter(tx -> { - SavingsAccountTransactionType t = txType(tx); - return t.isAccrual() && isReversed(tx); - }).count(); - Assertions.assertEquals(0L, reversedAccruals, "Accrual reversals were found in account transactions"); + /** Closing these keeps later runs of the interest posting job from having to walk them all. */ + private void closeTrackedSavingsAccounts() { + if (createdSavingsAccountIds.isEmpty()) { + return; + } + final String today = Utils.dateFormatter.format(Utils.getLocalDateOfTenant()); + createdSavingsAccountIds.forEach(savingsId -> { + try { + savingsHelper.postInterest(savingsId); + closeSavings(savingsId, today, true); + } catch (RuntimeException e) { + LOG.warn("Unable to close savings account {}: {}", savingsId, e.getMessage()); + } + }); + createdSavingsAccountIds.clear(); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsProductCreationIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsProductCreationIntegrationTest.java index ed22f7d88b2..3969ba3c09d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsProductCreationIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsProductCreationIntegrationTest.java @@ -18,174 +18,145 @@ */ package org.apache.fineract.integrationtests; -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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigDecimal; +import org.apache.fineract.client.models.GetSavingsProductsAccountingMappings; import org.apache.fineract.client.models.GetSavingsProductsProductIdResponse; +import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.PutSavingsProductsProductIdRequest; import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; -import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; -import org.apache.fineract.integrationtests.common.Utils; +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.common.accounting.Account; -import org.apache.fineract.integrationtests.common.accounting.AccountHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; -import org.apache.fineract.integrationtests.common.savings.SavingsTestLifecycleExtension; -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; - -@ExtendWith({ SavingsTestLifecycleExtension.class }) -public class SavingsProductCreationIntegrationTest { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsProductCreationIntegrationTest.class); - private static ResponseSpecification responseSpec; - private static RequestSpecification requestSpec; - private AccountHelper accountHelper; - private SavingsAccountHelper savingsAccountHelper; - public static final String MINIMUM_OPENING_BALANCE = "1000.0"; - - @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.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); - this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); - } + +public class SavingsProductCreationIntegrationTest extends FeignSavingsTestBase { + + private static final BigDecimal MINIMUM_OPENING_BALANCE = new BigDecimal("1000.0"); + private static final BigDecimal OVERDRAFT_LIMIT = new BigDecimal("100000"); + private static final String INTEREST_RECEIVABLE_ACCOUNT = "interestReceivableAccount"; @Test public void testStandardSavingsProductCreation_DoesNotAllowOverdraft() { - // --- ARRANGE --- - final Account assetAccount = this.accountHelper.createAssetAccount(); - final Account incomeAccount = this.accountHelper.createIncomeAccount(); - final Account expenseAccount = this.accountHelper.createExpenseAccount(); - final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); - - final Integer savingsProductID = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(MINIMUM_OPENING_BALANCE, - assetAccount, incomeAccount, expenseAccount, liabilityAccount); - final GetSavingsProductsProductIdResponse savingsProductsResponse = SavingsProductHelper.getSavingsProductById(requestSpec, - responseSpec, savingsProductID); - Assertions.assertNotNull(savingsProductsResponse); - Assertions.assertNotNull(savingsProductsResponse.getAccountingMappings()); - Assertions.assertNull(savingsProductsResponse.getAccountingMappings().getInterestReceivableAccount()); + final Account assetAccount = accountHelper.createAssetAccount("assetAccount"); + final Account incomeAccount = accountHelper.createIncomeAccount("incomeAccount"); + final Account expenseAccount = accountHelper.createExpenseAccount("expenseAccount"); + final Account liabilityAccount = accountHelper.createLiabilityAccount("liabilityAccount"); + + final Long productId = savingsProductHelper.createSavingsProduct(SavingsRequestBuilders + .withAccrualAccountingMappings(accrualProduct(), assetAccount, liabilityAccount, incomeAccount, expenseAccount)) + .getResourceId(); + assertNotNull(productId); + + assertNull(getAccountingMappings(productId).getInterestReceivableAccount(), + "A product without an overdraft should not carry an interest receivable account"); } @Test public void testSavingsProductWithOverdraftCreation_AllowsOverdraft() { - // --- ARRANGE --- - final Account assetAccount = this.accountHelper.createAssetAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account incomeAccount = this.accountHelper.createIncomeAccount(); - final Account expenseAccount = this.accountHelper.createExpenseAccount(); - final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); - - final Integer savingsProductID = createSavingsProductWithAccrualAccountingWithOverdraftAllowed( - interestReceivableAccount.getAccountID().toString(), MINIMUM_OPENING_BALANCE, assetAccount, incomeAccount, expenseAccount, - liabilityAccount); - final GetSavingsProductsProductIdResponse savingsProductsResponse = SavingsProductHelper.getSavingsProductById(requestSpec, - responseSpec, savingsProductID); - Assertions.assertNotNull(savingsProductsResponse); - Assertions.assertNotNull(savingsProductsResponse.getAccountingMappings()); - Assertions.assertNotNull(savingsProductsResponse.getAccountingMappings().getInterestReceivableAccount()); - - Assertions.assertEquals(interestReceivableAccount.getAccountID(), - savingsProductsResponse.getAccountingMappings().getInterestReceivableAccount().getId().intValue()); - + final Account assetAccount = accountHelper.createAssetAccount("assetAccount"); + final Account interestReceivableAccount = accountHelper.createAssetAccount(INTEREST_RECEIVABLE_ACCOUNT); + final Account incomeAccount = accountHelper.createIncomeAccount("incomeAccount"); + final Account expenseAccount = accountHelper.createExpenseAccount("expenseAccount"); + final Account liabilityAccount = accountHelper.createLiabilityAccount("liabilityAccount"); + + final Long productId = savingsProductHelper + .createSavingsProduct(SavingsRequestBuilders.withAccrualAccountingMappings(overdraftAccrualProduct(), assetAccount, + liabilityAccount, incomeAccount, expenseAccount, interestReceivableAccount)) + .getResourceId(); + assertNotNull(productId); + + verifyInterestReceivableAccount(productId, interestReceivableAccount); } @Test public void testSavingsProductWithOverdraftUpdate_AllowsOverdraft() { - // --- ARRANGE --- - final Account assetAccount = this.accountHelper.createAssetAccount(); - final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - final Account incomeAccount = this.accountHelper.createIncomeAccount(); - final Account expenseAccount = this.accountHelper.createExpenseAccount(); - final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); - - final Integer savingsProductID = createSavingsProductWithAccrualAccountingWithOverdraftAllowed( - interestReceivableAccount.getAccountID().toString(), MINIMUM_OPENING_BALANCE, assetAccount, incomeAccount, expenseAccount, - liabilityAccount); - final GetSavingsProductsProductIdResponse savingsProductsResponse = SavingsProductHelper.getSavingsProductById(requestSpec, - responseSpec, savingsProductID); - - Assertions.assertNotNull(savingsProductsResponse); - Assertions.assertNotNull(savingsProductsResponse.getAccountingMappings()); - Assertions.assertNotNull(savingsProductsResponse.getAccountingMappings().getInterestReceivableAccount()); - - Assertions.assertEquals(interestReceivableAccount.getAccountID(), - savingsProductsResponse.getAccountingMappings().getInterestReceivableAccount().getId().intValue()); - - final Account newInterestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); - - final Integer savingsProductIDupdate = updateSavingsProductWithAccrualAccountingWithOverdraftAllowed(savingsProductID, - newInterestReceivableAccount.getAccountID().toString(), MINIMUM_OPENING_BALANCE, assetAccount, incomeAccount, - expenseAccount, liabilityAccount); - - final GetSavingsProductsProductIdResponse savingsProductsResponseUpdate = SavingsProductHelper.getSavingsProductById(requestSpec, - responseSpec, savingsProductIDupdate); - - Assertions.assertNotNull(savingsProductsResponseUpdate); - Assertions.assertNotNull(savingsProductsResponseUpdate.getAccountingMappings()); - Assertions.assertNotNull(savingsProductsResponseUpdate.getAccountingMappings().getInterestReceivableAccount()); - - Assertions.assertEquals(newInterestReceivableAccount.getAccountID(), - savingsProductsResponseUpdate.getAccountingMappings().getInterestReceivableAccount().getId().intValue()); - Assertions.assertNotEquals(interestReceivableAccount.getAccountID(), - savingsProductsResponseUpdate.getAccountingMappings().getInterestReceivableAccount().getId().intValue()); - + final Account assetAccount = accountHelper.createAssetAccount("assetAccount"); + final Account interestReceivableAccount = accountHelper.createAssetAccount(INTEREST_RECEIVABLE_ACCOUNT); + final Account incomeAccount = accountHelper.createIncomeAccount("incomeAccount"); + final Account expenseAccount = accountHelper.createExpenseAccount("expenseAccount"); + final Account liabilityAccount = accountHelper.createLiabilityAccount("liabilityAccount"); + + final PostSavingsProductsRequest createdProduct = SavingsRequestBuilders.withAccrualAccountingMappings(overdraftAccrualProduct(), + assetAccount, liabilityAccount, incomeAccount, expenseAccount, interestReceivableAccount); + final Long productId = savingsProductHelper.createSavingsProduct(createdProduct).getResourceId(); + assertNotNull(productId); + verifyInterestReceivableAccount(productId, interestReceivableAccount); + + final Account newInterestReceivableAccount = accountHelper.createAssetAccount(INTEREST_RECEIVABLE_ACCOUNT); + final Long updatedProductId = savingsProductHelper + .updateSavingsProduct(productId, SavingsRequestBuilders.withAccrualAccountingMappings(updateOf(createdProduct), + assetAccount, liabilityAccount, incomeAccount, expenseAccount, newInterestReceivableAccount)) + .getResourceId(); + assertEquals(productId, updatedProductId, "The update should have been applied to the same product"); + + verifyInterestReceivableAccount(updatedProductId, newInterestReceivableAccount); + assertNotEquals(interestReceivableAccount.getAccountID(), + getAccountingMappings(updatedProductId).getInterestReceivableAccount().getId().intValue(), + "The interest receivable account should no longer be the one the product was created with"); } @Test public void testRetrieveSavingsProductsWithOfficeSpecificRestrictionEnabledAndNoEntityAccessMapping() { - final GlobalConfigurationHelper globalConfigurationHelper = new GlobalConfigurationHelper(); try { globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.OFFICE_SPECIFIC_PRODUCTS_ENABLED, true); globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.RESTRICT_PRODUCTS_TO_USER_OFFICE, true); - final String savingsProducts = SavingsProductHelper.retrieveAllSavingsProducts(requestSpec, responseSpec); - Assertions.assertNotNull(savingsProducts); + assertNotNull(savingsProductHelper.getAllSavingsProducts()); } finally { globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.RESTRICT_PRODUCTS_TO_USER_OFFICE, false); globalConfigurationHelper.manageConfigurations(GlobalConfigurationConstants.OFFICE_SPECIFIC_PRODUCTS_ENABLED, false); } } - public static Integer createSavingsProductWithAccrualAccountingWithOverdraftAllowed(final String interestReceivableAccount, - final String minOpenningBalance, final Account... accounts) { - LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT WITH OVERDRAFT ---------------------------------------"); - final String savingsProductJSON = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() // - .withInterestPostingPeriodTypeAsQuarterly() // - .withInterestCalculationPeriodTypeAsDailyBalance() // - .withOverDraft("100000").withAccountInterestReceivables(interestReceivableAccount) - .withMinimumOpenningBalance(minOpenningBalance).withAccountingRuleAsAccrualBased(accounts).build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private void verifyInterestReceivableAccount(final Long productId, final Account expectedAccount) { + final GetSavingsProductsAccountingMappings mappings = getAccountingMappings(productId); + assertNotNull(mappings.getInterestReceivableAccount(), "Interest receivable account is missing"); + assertEquals(expectedAccount.getAccountID(), mappings.getInterestReceivableAccount().getId().intValue()); + } + + private GetSavingsProductsAccountingMappings getAccountingMappings(final Long productId) { + final GetSavingsProductsProductIdResponse product = getSavingsProduct(productId); + assertNotNull(product); + assertNotNull(product.getAccountingMappings(), "Accounting mappings are missing"); + return product.getAccountingMappings(); } - public static Integer createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(final String minOpenningBalance, - final Account... accounts) { - LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT WITHOUT OVERDRAFT ---------------------------------------"); - final String savingsProductJSON = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() // - .withInterestPostingPeriodTypeAsQuarterly() // - .withInterestCalculationPeriodTypeAsDailyBalance() // - .withMinimumOpenningBalance(minOpenningBalance).withAccountingRuleAsAccrualBased(accounts).build(); - return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + private PostSavingsProductsRequest overdraftAccrualProduct() { + return accrualProduct().allowOverdraft(true).overdraftLimit(OVERDRAFT_LIMIT); } - public static Integer updateSavingsProductWithAccrualAccountingWithOverdraftAllowed(final Integer productId, - final String interestReceivableAccount, final String minOpenningBalance, final Account... accounts) { - LOG.info("------------------------------UPDATE SAVINGS PRODUCT ACCOUNT ---------------------------------------"); - final String savingsProductJSON = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() // - .withInterestPostingPeriodTypeAsQuarterly() // - .withInterestCalculationPeriodTypeAsDailyBalance() // - .withOverDraft("100000").withAccountInterestReceivables(interestReceivableAccount) - .withMinimumOpenningBalance(minOpenningBalance).withAccountingRuleAsAccrualBased(accounts).build(); - return SavingsProductHelper.updateSavingsProduct(savingsProductJSON, requestSpec, responseSpec, productId); + private PostSavingsProductsRequest accrualProduct() { + return SavingsRequestBuilders + .savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, SavingsTestData.InterestPostingPeriodType.QUARTERLY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE) + .minRequiredOpeningBalance(MINIMUM_OPENING_BALANCE)// + .accountingRule(SavingsTestData.AccountingRule.ACCRUAL_PERIODIC); } + /** Carries the created product's own values, so the update changes only the accounting mappings under test. */ + private PutSavingsProductsProductIdRequest updateOf(final PostSavingsProductsRequest product) { + return new PutSavingsProductsProductIdRequest()// + .name(product.getName())// + .shortName(product.getShortName())// + .description(product.getDescription())// + .currencyCode(product.getCurrencyCode())// + .digitsAfterDecimal(product.getDigitsAfterDecimal())// + .inMultiplesOf(product.getInMultiplesOf())// + .nominalAnnualInterestRate(product.getNominalAnnualInterestRate())// + .minRequiredOpeningBalance(product.getMinRequiredOpeningBalance())// + .interestCompoundingPeriodType(product.getInterestCompoundingPeriodType())// + .interestPostingPeriodType(product.getInterestPostingPeriodType())// + .interestCalculationType(product.getInterestCalculationType())// + .interestCalculationDaysInYearType(product.getInterestCalculationDaysInYearType())// + .accountingRule(product.getAccountingRule())// + .allowOverdraft(product.getAllowOverdraft())// + .overdraftLimit(product.getOverdraftLimit())// + .locale(SavingsTestData.LOCALE); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignSavingsTestBase.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignSavingsTestBase.java index 7300db26935..b9e2331c502 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignSavingsTestBase.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignSavingsTestBase.java @@ -33,11 +33,20 @@ import org.apache.fineract.client.models.SavingsAccountData; import org.apache.fineract.client.models.SavingsAccountStatusEnumData; import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountTransferHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper; import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignDatatableHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignGlobalConfigurationHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignJournalEntryHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignPaymentTypeHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignSavingsChargeHelper; import org.apache.fineract.integrationtests.client.feign.helpers.FeignSavingsHelper; import org.apache.fineract.integrationtests.client.feign.helpers.FeignSavingsLifecycleExtension; import org.apache.fineract.integrationtests.client.feign.helpers.FeignSavingsProductHelper; import org.apache.fineract.integrationtests.client.feign.helpers.FeignSavingsTransactionHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignSchedulerHelper; import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; @@ -49,6 +58,15 @@ public abstract class FeignSavingsTestBase extends FeignIntegrationTest { protected static FeignSavingsTransactionHelper savingsTransactionHelper; protected static FeignSavingsProductHelper savingsProductHelper; protected static FeignClientHelper clientHelper; + protected static FeignGlobalConfigurationHelper globalConfigurationHelper; + protected static FeignBusinessDateHelper businessDateHelper; + protected static FeignAccountHelper accountHelper; + protected static FeignJournalEntryHelper journalEntryHelper; + protected static FeignSchedulerHelper schedulerHelper; + protected static FeignDatatableHelper datatableHelper; + protected static FeignSavingsChargeHelper savingsChargeHelper; + protected static FeignAccountTransferHelper accountTransferHelper; + protected static FeignPaymentTypeHelper paymentTypeHelper; @BeforeAll public static void setupSavingsHelpers() { @@ -57,6 +75,15 @@ public static void setupSavingsHelpers() { savingsTransactionHelper = new FeignSavingsTransactionHelper(client); savingsProductHelper = new FeignSavingsProductHelper(client); clientHelper = new FeignClientHelper(client); + globalConfigurationHelper = new FeignGlobalConfigurationHelper(client); + businessDateHelper = new FeignBusinessDateHelper(client); + accountHelper = new FeignAccountHelper(client); + journalEntryHelper = new FeignJournalEntryHelper(client); + schedulerHelper = new FeignSchedulerHelper(client); + datatableHelper = new FeignDatatableHelper(client); + savingsChargeHelper = new FeignSavingsChargeHelper(client); + accountTransferHelper = new FeignAccountTransferHelper(client); + paymentTypeHelper = new FeignPaymentTypeHelper(client); } protected Long createClient() { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountTransferHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountTransferHelper.java new file mode 100644 index 00000000000..5d56280bfb1 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountTransferHelper.java @@ -0,0 +1,38 @@ +/** + * 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.integrationtests.client.feign.helpers; + +import static org.apache.fineract.client.feign.util.FeignCalls.ok; + +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.AccountTransferRequest; +import org.apache.fineract.client.models.PostAccountTransfersResponse; + +public class FeignAccountTransferHelper { + + private final FineractFeignClient fineractClient; + + public FeignAccountTransferHelper(FineractFeignClient fineractClient) { + this.fineractClient = fineractClient; + } + + public PostAccountTransfersResponse createAccountTransfer(AccountTransferRequest request) { + return ok(() -> fineractClient.accountTransfers().createAccountTransfer(request)); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignDatatableHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignDatatableHelper.java new file mode 100644 index 00000000000..3152193bb70 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignDatatableHelper.java @@ -0,0 +1,94 @@ +/** + * 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.integrationtests.client.feign.helpers; + +import static org.apache.fineract.client.feign.util.FeignCalls.ok; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.ObjectMapperFactory; +import org.apache.fineract.client.models.DeleteDataTablesDatatableAppTableIdResponse; +import org.apache.fineract.client.models.DeleteDataTablesResponse; +import org.apache.fineract.client.models.GetDataTablesResponse; +import org.apache.fineract.client.models.PostDataTablesAppTableIdResponse; +import org.apache.fineract.client.models.PostDataTablesRequest; +import org.apache.fineract.client.models.PostDataTablesResponse; +import org.apache.fineract.client.models.PutDataTablesAppTableIdDatatableIdResponse; +import org.apache.fineract.client.models.PutDataTablesRequest; +import org.apache.fineract.client.models.PutDataTablesResponse; + +public class FeignDatatableHelper { + + private static final String GENERIC_RESULT_SET = "genericResultSet"; + + private final FineractFeignClient fineractClient; + + public FeignDatatableHelper(FineractFeignClient fineractClient) { + this.fineractClient = fineractClient; + } + + public PostDataTablesResponse createDatatable(PostDataTablesRequest request) { + return ok(() -> fineractClient.dataTables().createDatatable(request)); + } + + public PutDataTablesResponse updateDatatable(String datatableName, PutDataTablesRequest request) { + return ok(() -> fineractClient.dataTables().updateDatatable(datatableName, request)); + } + + public GetDataTablesResponse getDatatable(String datatableName) { + return ok(() -> fineractClient.dataTables().getDatatable(datatableName)); + } + + public DeleteDataTablesResponse deleteDatatable(String datatableName) { + return ok(() -> fineractClient.dataTables().deleteDatatable(datatableName)); + } + + public PostDataTablesAppTableIdResponse createDatatableEntry(String datatableName, Long apptableId, Map entry) { + return ok(() -> fineractClient.dataTables().createDatatableEntry(datatableName, apptableId, asJson(entry))); + } + + public PutDataTablesAppTableIdDatatableIdResponse updateDatatableEntry(String datatableName, Long apptableId, Long datatableId, + Map entry) { + return ok(() -> fineractClient.dataTables().updateDatatableEntryOneToMany(datatableName, apptableId, datatableId, asJson(entry))); + } + + public DeleteDataTablesDatatableAppTableIdResponse deleteDatatableEntries(String datatableName, Long apptableId) { + return ok(() -> fineractClient.dataTables().deleteDatatableEntries(datatableName, apptableId)); + } + + public JsonNode getDatatableEntries(String datatableName, Long apptableId) { + String json = ok( + () -> fineractClient.dataTables().getDatatableEntries(datatableName, apptableId, Map.of(GENERIC_RESULT_SET, Boolean.TRUE))); + try { + return ObjectMapperFactory.getShared().readTree(json); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to parse the entries of datatable " + datatableName, e); + } + } + + private String asJson(Map entry) { + try { + return ObjectMapperFactory.getShared().writeValueAsString(entry); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialise the datatable entry " + entry, e); + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java index 4d36e251ed8..9e3de7d35e9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java @@ -19,13 +19,21 @@ package org.apache.fineract.integrationtests.client.feign.helpers; import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.models.GetGlobalConfigurationsResponse; import org.apache.fineract.client.models.GlobalConfigurationPropertyData; import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; +@SuppressWarnings("rawtypes") +@Slf4j public class FeignGlobalConfigurationHelper { private final FineractFeignClient fineractClient; @@ -61,8 +69,72 @@ public Long getConfigurationIdByName(String configName) { .orElseThrow(() -> new RuntimeException("Configuration not found: " + configName)); } + public GlobalConfigurationPropertyData getGlobalConfigurationByName(String configName) { + return ok(() -> fineractClient.globalConfiguration().retrieveOneByName(configName)); + } + private List getConfigurationList() { GetGlobalConfigurationsResponse response = ok(() -> fineractClient.globalConfiguration().retrieveConfiguration(false)); return response.getGlobalConfiguration(); } + + /** Restores every modifiable global configuration to its default so a test cannot leak state into the next one. */ + public void resetAllDefaultGlobalConfigurations() { + Map defaults = defaultsByName(); + + int changedNo = 0; + for (GlobalConfigurationPropertyData actual : getConfigurationList()) { + HashMap expected = defaults.get(actual.getName()); + if (expected == null) { + throw new IllegalStateException("Global configuration '" + actual.getName() + + "' found in database but not in integration test defaults. " + + "You must add it to GlobalConfigurationHelper.getAllDefaultGlobalConfigurations() to ensure test isolation."); + } + if (isMatching(expected, actual)) { + continue; + } + // trapDoor configurations reject updates with GlobalConfigurationPropertyCannotBeModfied. + if ((Boolean) expected.get("trapDoor")) { + continue; + } + updateGlobalConfiguration((String) expected.get("name"), + new PutGlobalConfigurationsRequest().value((Long) expected.get("value")).enabled((Boolean) expected.get("enabled"))); + changedNo++; + } + log.info("--------------------------------- UPDATED GLOBAL CONFIG ENTRY SIZE: {} ---------------------------------------------", + changedNo); + } + + /** Fails the test class if any global configuration is left away from its default. */ + public void verifyAllDefaultGlobalConfigurations() { + Map expectedByName = defaultsByName(); + List actualConfigurations = getConfigurationList(); + + assertEquals(expectedByName.size(), actualConfigurations.size(), "Unexpected number of global configurations"); + + for (GlobalConfigurationPropertyData actual : actualConfigurations) { + String configName = actual.getName(); + HashMap expected = expectedByName.get(configName); + assertNotNull(expected, "Configuration found in API but not in expected defaults: " + configName); + + final String assertionFailedMessage = "Assertion failed for configName:<" + configName + ">"; + assertEquals(expected.get("name"), actual.getName(), assertionFailedMessage); + assertEquals(expected.get("value"), actual.getValue(), assertionFailedMessage); + assertEquals(expected.get("enabled"), actual.getEnabled(), assertionFailedMessage); + assertEquals(expected.get("trapDoor"), actual.getTrapDoor(), assertionFailedMessage); + } + } + + private static Map defaultsByName() { + Map defaultsByName = new HashMap<>(); + for (HashMap config : GlobalConfigurationHelper.getAllDefaultGlobalConfigurations()) { + defaultsByName.put((String) config.get("name"), config); + } + return defaultsByName; + } + + private static boolean isMatching(HashMap expected, GlobalConfigurationPropertyData actual) { + return expected.get("name").equals(actual.getName()) && expected.get("value").equals(actual.getValue()) + && expected.get("enabled").equals(actual.getEnabled()) && expected.get("trapDoor").equals(actual.getTrapDoor()); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignPaymentTypeHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignPaymentTypeHelper.java new file mode 100644 index 00000000000..6d07fce878e --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignPaymentTypeHelper.java @@ -0,0 +1,38 @@ +/** + * 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.integrationtests.client.feign.helpers; + +import static org.apache.fineract.client.feign.util.FeignCalls.ok; + +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.PaymentTypeCreateRequest; +import org.apache.fineract.client.models.PaymentTypeCreateResponse; + +public class FeignPaymentTypeHelper { + + private final FineractFeignClient fineractClient; + + public FeignPaymentTypeHelper(FineractFeignClient fineractClient) { + this.fineractClient = fineractClient; + } + + public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) { + return ok(() -> fineractClient.paymentType().createPaymentType(request)); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsChargeHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsChargeHelper.java new file mode 100644 index 00000000000..26d18c32289 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsChargeHelper.java @@ -0,0 +1,55 @@ +/** + * 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.integrationtests.client.feign.helpers; + +import static org.apache.fineract.client.feign.util.FeignCalls.ok; + +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesRequest; +import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesResponse; +import org.apache.fineract.integrationtests.client.feign.modules.SavingsRequestBuilders; + +public class FeignSavingsChargeHelper { + + private final FineractFeignClient fineractClient; + + public FeignSavingsChargeHelper(FineractFeignClient fineractClient) { + this.fineractClient = fineractClient; + } + + public PostChargesResponse createWithdrawalFeeCharge() { + return createCharge(SavingsRequestBuilders.savingsWithdrawalFeeCharge()); + } + + public PostChargesResponse createCharge(ChargeRequest request) { + return ok(() -> fineractClient.charges().createCharge(request)); + } + + public PostSavingsAccountsSavingsAccountIdChargesResponse addChargeToSavings(Long savingsId, + PostSavingsAccountsSavingsAccountIdChargesRequest request) { + return ok(() -> fineractClient.savingsCharges().createSavingsAccountCharge(savingsId, request)); + } + + public PostSavingsAccountsSavingsAccountIdChargesResponse addChargeToSavings(Long savingsId, Long chargeId, Float amount) { + return addChargeToSavings(savingsId, SavingsRequestBuilders.savingsAccountCharge(chargeId, amount)); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsHelper.java index aad50c7cb25..d64bb5ce477 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsHelper.java @@ -27,7 +27,11 @@ import org.apache.fineract.client.models.PostSavingsAccountsAccountIdResponse; import org.apache.fineract.client.models.PostSavingsAccountsRequest; import org.apache.fineract.client.models.PostSavingsAccountsResponse; +import org.apache.fineract.client.models.PutSavingsAccountsAccountIdRequest; +import org.apache.fineract.client.models.PutSavingsAccountsAccountIdResponse; import org.apache.fineract.client.models.SavingsAccountData; +import org.apache.fineract.client.models.SavingsAccountStatusEnumData; +import org.apache.fineract.client.models.SavingsAccountSummaryData; import org.apache.fineract.integrationtests.client.feign.modules.SavingsRequestBuilders; public class FeignSavingsHelper { @@ -75,14 +79,42 @@ public SavingsAccountData getSavingsDetails(Long savingsId) { return ok(() -> fineractClient.savingsAccount().retrieveSavingsAccount(savingsId, Map.of("associations", "all"))); } - public SavingsAccountData getSavingsDetails(Long savingsId, String associations) { - return ok(() -> fineractClient.savingsAccount().retrieveSavingsAccount(savingsId, Map.of("associations", associations))); - } - public DeleteSavingsAccountsAccountIdResponse deleteSavingsApplication(Long savingsId) { return ok(() -> fineractClient.savingsAccount().deleteSavingsAccount(savingsId)); } + public PostSavingsAccountsAccountIdResponse withdrawnByApplicant(Long savingsId, String withdrawnOnDate) { + PostSavingsAccountsAccountIdRequest request = SavingsRequestBuilders.withdrawnByApplicant(withdrawnOnDate); + return ok(() -> fineractClient.savingsAccount().handleCommandsSavingsAccount(savingsId, request, "withdrawnByApplicant")); + } + + public PostSavingsAccountsAccountIdResponse postInterest(Long savingsId) { + PostSavingsAccountsAccountIdRequest request = new PostSavingsAccountsAccountIdRequest(); + return ok(() -> fineractClient.savingsAccount().handleCommandsSavingsAccount(savingsId, request, "postInterest")); + } + + public PostSavingsAccountsAccountIdResponse calculateInterest(Long savingsId) { + PostSavingsAccountsAccountIdRequest request = new PostSavingsAccountsAccountIdRequest(); + return ok(() -> fineractClient.savingsAccount().handleCommandsSavingsAccount(savingsId, request, "calculateInterest")); + } + + public PutSavingsAccountsAccountIdResponse updateSavingsAccount(Long savingsId, PutSavingsAccountsAccountIdRequest request) { + return ok(() -> fineractClient.savingsAccount().updateSavingsAccount(savingsId, request, (String) null)); + } + + public SavingsAccountStatusEnumData getSavingsStatus(Long savingsId) { + return getSavingsAccount(savingsId).getStatus(); + } + + public SavingsAccountSummaryData getSavingsSummary(Long savingsId) { + return getSavingsAccount(savingsId).getSummary(); + } + + /** Status and summary are on the account itself, so no associations are requested. */ + private SavingsAccountData getSavingsAccount(Long savingsId) { + return ok(() -> fineractClient.savingsAccount().retrieveSavingsAccount(savingsId, Map.of())); + } + public Long createApproveActivateSavings(Long clientId, Long productId, String date) { Long savingsId = submitApplication(clientId, productId, date).getSavingsId(); approveSavings(savingsId, date); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsProductHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsProductHelper.java index a83c91fde16..cedc7eef71d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsProductHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsProductHelper.java @@ -20,10 +20,14 @@ import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import java.util.List; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.models.GetSavingsProductsProductIdResponse; +import org.apache.fineract.client.models.GetSavingsProductsResponse; import org.apache.fineract.client.models.PostSavingsProductsRequest; import org.apache.fineract.client.models.PostSavingsProductsResponse; +import org.apache.fineract.client.models.PutSavingsProductsProductIdRequest; +import org.apache.fineract.client.models.PutSavingsProductsProductIdResponse; import org.apache.fineract.integrationtests.client.feign.modules.SavingsRequestBuilders; public class FeignSavingsProductHelper { @@ -45,4 +49,12 @@ public PostSavingsProductsResponse createDefaultSavingsProduct() { public GetSavingsProductsProductIdResponse getSavingsProduct(Long productId) { return ok(() -> fineractClient.savingsProduct().retrieveOneSavingsProduct(productId)); } + + public PutSavingsProductsProductIdResponse updateSavingsProduct(Long productId, PutSavingsProductsProductIdRequest request) { + return ok(() -> fineractClient.savingsProduct().updateSavingsProduct(productId, request)); + } + + public List getAllSavingsProducts() { + return ok(() -> fineractClient.savingsProduct().retrieveAllSavingsProducts()); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsTransactionHelper.java index 6e9192a9533..176da8d1881 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSavingsTransactionHelper.java @@ -18,11 +18,25 @@ */ package org.apache.fineract.integrationtests.client.feign.helpers; +import static org.apache.fineract.client.feign.util.FeignCalls.fail; import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import com.fasterxml.jackson.core.JsonProcessingException; +import feign.FeignException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Map; import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.ObjectMapperFactory; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.CommandProcessingResult; +import org.apache.fineract.client.models.PostSavingsAccountBulkReversalTransactionsRequest; import org.apache.fineract.client.models.PostSavingsAccountTransactionsRequest; import org.apache.fineract.client.models.PostSavingsAccountTransactionsResponse; +import org.apache.fineract.client.models.SavingsAccountData; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +import org.apache.fineract.client.models.SavingsAccountTransactionsSearchResponse; import org.apache.fineract.integrationtests.client.feign.modules.SavingsRequestBuilders; public class FeignSavingsTransactionHelper { @@ -48,4 +62,93 @@ public PostSavingsAccountTransactionsResponse withdraw(Long savingsId, PostSavin public PostSavingsAccountTransactionsResponse withdraw(Long savingsId, String amount, String transactionDate) { return withdraw(savingsId, SavingsRequestBuilders.withdrawal(amount, transactionDate)); } + + /** The server only accepts this command while the force-withdrawal global configuration is enabled. */ + public PostSavingsAccountTransactionsResponse forceWithdraw(Long savingsId, PostSavingsAccountTransactionsRequest request) { + return ok( + () -> fineractClient.savingsAccountTransactions().createSavingsAccountTransaction(savingsId, request, "force-withdrawal")); + } + + public PostSavingsAccountTransactionsResponse forceWithdraw(Long savingsId, String amount, String transactionDate) { + return forceWithdraw(savingsId, SavingsRequestBuilders.withdrawal(amount, transactionDate)); + } + + public CallFailedRuntimeException withdrawExpectingError(Long savingsId, String amount, String transactionDate) { + PostSavingsAccountTransactionsRequest request = SavingsRequestBuilders.withdrawal(amount, transactionDate); + return fail(() -> fineractClient.savingsAccountTransactions().createSavingsAccountTransaction(savingsId, request, "withdrawal")); + } + + public PostSavingsAccountTransactionsResponse postInterestAsOn(Long savingsId, String transactionDate) { + PostSavingsAccountTransactionsRequest request = SavingsRequestBuilders.postInterestAsOn(transactionDate); + return ok( + () -> fineractClient.savingsAccountTransactions().createSavingsAccountTransaction(savingsId, request, "postInterestAsOn")); + } + + public PostSavingsAccountTransactionsResponse holdAmount(Long savingsId, String amount, String transactionDate, String reasonForBlock) { + PostSavingsAccountTransactionsRequest request = SavingsRequestBuilders.holdAmount(amount, transactionDate, reasonForBlock); + return ok(() -> fineractClient.savingsAccountTransactions().createSavingsAccountTransaction(savingsId, request, "holdAmount")); + } + + public CommandProcessingResult reverseTransaction(Long savingsId, Long transactionId) { + return adjustTransaction(savingsId, transactionId, "reverse"); + } + + public CommandProcessingResult undoTransaction(Long savingsId, Long transactionId) { + return adjustTransaction(savingsId, transactionId, "undo"); + } + + public CommandProcessingResult releaseAmount(Long savingsId, Long holdTransactionId) { + return adjustTransaction(savingsId, holdTransactionId, "releaseAmount"); + } + + /** All three commands are driven by the query parameter alone and read no payload, hence the empty body. */ + private CommandProcessingResult adjustTransaction(Long savingsId, Long transactionId, String command) { + PostSavingsAccountBulkReversalTransactionsRequest request = new PostSavingsAccountBulkReversalTransactionsRequest(); + return ok(() -> fineractClient.create(SavingsTransactionCommandsApi.class).adjustSavingsAccountTransaction(savingsId, transactionId, + command, request)); + } + + /** The generated client types this response as a bare {@code String}, so the JSON is mapped here. */ + public SavingsAccountTransactionData getTransaction(Long savingsId, Long transactionId) { + String json = ok(() -> fineractClient.savingsAccountTransactions().retrieveOneSavingsAccountTransaction(savingsId, transactionId)); + try { + return ObjectMapperFactory.getShared().readValue(json, SavingsAccountTransactionData.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to parse savings transaction " + transactionId, e); + } + } + + public List getTransactions(Long savingsId) { + SavingsAccountData savings = ok( + () -> fineractClient.savingsAccount().retrieveSavingsAccount(savingsId, Map.of("associations", "transactions"))); + return savings.getTransactions(); + } + + public List getAccrualTransactions(Long savingsId) { + return getTransactions(savingsId).stream().filter(FeignSavingsTransactionHelper::isAccrual).toList(); + } + + /** Reversed accruals are included, matching what the account's own accrual total reflects. */ + public BigDecimal getTotalAccrualAmount(Long savingsId) { + return getAccrualTransactions(savingsId).stream().map(SavingsAccountTransactionData::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + } + + private static boolean isAccrual(SavingsAccountTransactionData transaction) { + return transaction.getTransactionType() != null && Boolean.TRUE.equals(transaction.getTransactionType().getAccrual()); + } + + public SavingsAccountTransactionsSearchResponse searchTransactions(Long savingsId, Map queryParams) { + return ok(() -> fineractClient.savingsAccountTransactions().searchSavingsAccountTransactions(savingsId, queryParams)); + } + + /** The error decoder only builds a typed error when the response has a body, so a bodiless rejection needs both. */ + public int searchTransactionsExpectingErrorStatus(Long savingsId, Map queryParams) { + try { + return fail(() -> fineractClient.savingsAccountTransactions().searchSavingsAccountTransactions(savingsId, queryParams)) + .getStatus(); + } catch (FeignException e) { + return e.status(); + } + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java index a55547cbb07..213c0973b01 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignSchedulerHelper.java @@ -19,6 +19,8 @@ package org.apache.fineract.integrationtests.client.feign.helpers; import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; import java.time.Duration; import java.util.List; @@ -58,12 +60,19 @@ public void executeAndAwaitJob(String jobDisplayName) { Long previousRunHistoryId = getRunHistoryId(getLatestJobRunHistory(jobId)); FeignCalls.executeVoid(() -> fineractClient.schedulerJob().executeJob(jobId, "executeJob", new ExecuteJobRequest())); - Awaitility.await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(1)).pollDelay(Duration.ofSeconds(1)) - .until(() -> isNewCompletedRunHistory(jobId, previousRunHistoryId)); + JobDetailHistoryDataSwagger finalRunHistory = Awaitility.await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(1)) + .pollDelay(Duration.ofSeconds(1)) + .until(() -> getLatestJobRunHistory(jobId), runHistory -> isNewCompletedRunHistory(runHistory, previousRunHistoryId)); + + assertEquals("application", finalRunHistory.getTriggerType(), + "Job was not triggered by the test for jobId=" + jobId + ": " + finalRunHistory); + String status = finalRunHistory.getStatus(); + if (!"success".equals(status)) { + fail("Job status is not success for jobId=" + jobId + ": " + finalRunHistory); + } } - private boolean isNewCompletedRunHistory(Long jobId, Long previousRunHistoryId) { - JobDetailHistoryDataSwagger latestRunHistory = getLatestJobRunHistory(jobId); + private boolean isNewCompletedRunHistory(JobDetailHistoryDataSwagger latestRunHistory, Long previousRunHistoryId) { if (latestRunHistory == null || latestRunHistory.getJobRunEndTime() == null) { return false; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/SavingsTransactionCommandsApi.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/SavingsTransactionCommandsApi.java new file mode 100644 index 00000000000..a2a668e6c96 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/SavingsTransactionCommandsApi.java @@ -0,0 +1,39 @@ +/** + * 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.integrationtests.client.feign.helpers; + +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import org.apache.fineract.client.models.CommandProcessingResult; +import org.apache.fineract.client.models.PostSavingsAccountBulkReversalTransactionsRequest; + +/** + * Adjusts a savings transaction - {@code reverse}, {@code undo} or {@code releaseAmount}. Hand-written because + * {@code SavingsAccountTransactionsApiResource} documents the 200 as an array of request models, so the generated + * client cannot read the {@code resourceId} these tests assert on. Correcting that {@code @ApiResponse} is a breaking + * OpenAPI change, so it is prepared separately rather than carried by a test migration. + */ +@Headers({ "Accept: application/json", "Content-Type: application/json" }) +public interface SavingsTransactionCommandsApi { + + @RequestLine("POST /v1/savingsaccounts/{savingsId}/transactions/{transactionId}?command={command}") + CommandProcessingResult adjustSavingsAccountTransaction(@Param("savingsId") Long savingsId, @Param("transactionId") Long transactionId, + @Param("command") String command, PostSavingsAccountBulkReversalTransactionsRequest request); +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsRequestBuilders.java index 20ea3175295..cf3b8615581 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsRequestBuilders.java @@ -19,11 +19,15 @@ package org.apache.fineract.integrationtests.client.feign.modules; import java.math.BigDecimal; +import org.apache.fineract.client.models.ChargeRequest; import org.apache.fineract.client.models.PostSavingsAccountTransactionsRequest; import org.apache.fineract.client.models.PostSavingsAccountsAccountIdRequest; import org.apache.fineract.client.models.PostSavingsAccountsRequest; +import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesRequest; import org.apache.fineract.client.models.PostSavingsProductsRequest; +import org.apache.fineract.client.models.PutSavingsProductsProductIdRequest; import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; public final class SavingsRequestBuilders { @@ -46,6 +50,89 @@ public static PostSavingsProductsRequest defaultSavingsProduct() { .locale(SavingsTestData.LOCALE); } + /** Overdraft, enforced minimum balance and withholding tax are left off so they cannot skew interest figures. */ + public static PostSavingsProductsRequest savingsProduct(int interestCompoundingPeriodType, int interestPostingPeriodType, + int interestCalculationType) { + return defaultSavingsProduct()// + .interestCompoundingPeriodType(interestCompoundingPeriodType)// + .interestPostingPeriodType(interestPostingPeriodType)// + .interestCalculationType(interestCalculationType)// + .withdrawalFeeForTransfers(true)// + .allowOverdraft(false)// + .enforceMinRequiredBalance(false)// + .withHoldTax(false); + } + + /** + * Fills every mapping from one account per type, so several mappings share an account. Only for tests that need a + * valid accrual product; a test asserting which mapping was posted to must map each one to its own account. + */ + public static PostSavingsProductsRequest withAccrualAccountingMappings(PostSavingsProductsRequest request, Account assetAccount, + Account liabilityAccount, Account incomeAccount, Account expenseAccount) { + return request// + .savingsReferenceAccountId(accountId(assetAccount))// + .overdraftPortfolioControlId(accountId(assetAccount))// + .feesReceivableAccountId(accountId(assetAccount))// + .penaltiesReceivableAccountId(accountId(assetAccount))// + .savingsControlAccountId(accountId(liabilityAccount))// + .transfersInSuspenseAccountId(accountId(liabilityAccount))// + .interestPayableAccountId(accountId(liabilityAccount))// + .interestOnSavingsAccountId(accountId(expenseAccount))// + .writeOffAccountId(accountId(expenseAccount))// + .incomeFromFeeAccountId(accountId(incomeAccount))// + .incomeFromPenaltyAccountId(accountId(incomeAccount))// + .incomeFromInterestId(accountId(incomeAccount)); + } + + public static PostSavingsProductsRequest withAccrualAccountingMappings(PostSavingsProductsRequest request, Account assetAccount, + Account liabilityAccount, Account incomeAccount, Account expenseAccount, Account interestReceivableAccount) { + return withAccrualAccountingMappings(request, assetAccount, liabilityAccount, incomeAccount, expenseAccount) + .interestReceivableAccountId(accountId(interestReceivableAccount)); + } + + /** An update is validated against the same parameter set as a create, so it carries the whole product body. */ + public static PutSavingsProductsProductIdRequest withAccrualAccountingMappings(PutSavingsProductsProductIdRequest request, + Account assetAccount, Account liabilityAccount, Account incomeAccount, Account expenseAccount, + Account interestReceivableAccount) { + return request// + .savingsReferenceAccountId(accountId(assetAccount))// + .overdraftPortfolioControlId(accountId(assetAccount))// + .feesReceivableAccountId(accountId(assetAccount))// + .penaltiesReceivableAccountId(accountId(assetAccount))// + .savingsControlAccountId(accountId(liabilityAccount))// + .transfersInSuspenseAccountId(accountId(liabilityAccount))// + .interestPayableAccountId(accountId(liabilityAccount))// + .interestOnSavingsAccountId(accountId(expenseAccount))// + .writeOffAccountId(accountId(expenseAccount))// + .incomeFromFeeAccountId(accountId(incomeAccount))// + .incomeFromPenaltyAccountId(accountId(incomeAccount))// + .incomeFromInterestId(accountId(incomeAccount))// + .interestReceivableAccountId(accountId(interestReceivableAccount)); + } + + public static Long accountId(Account account) { + return account.getAccountID().longValue(); + } + + public static ChargeRequest savingsWithdrawalFeeCharge() { + return new ChargeRequest()// + .active(true)// + .name(Utils.uniqueRandomStringGenerator("Charge_Savings_", 6))// + .currencyCode(SavingsTestData.CURRENCY_CODE)// + .amount(SavingsTestData.DEFAULT_CHARGE_AMOUNT)// + .chargeAppliesTo(SavingsTestData.ChargeAppliesTo.SAVINGS)// + .chargeTimeType(SavingsTestData.ChargeTimeType.WITHDRAWAL_FEE)// + .chargeCalculationType(SavingsTestData.ChargeCalculationType.FLAT)// + .locale(SavingsTestData.LOCALE); + } + + public static PostSavingsAccountsSavingsAccountIdChargesRequest savingsAccountCharge(Long chargeId, Float amount) { + return new PostSavingsAccountsSavingsAccountIdChargesRequest()// + .chargeId(chargeId)// + .amount(amount)// + .locale(SavingsTestData.LOCALE); + } + public static PostSavingsAccountsRequest submitSavingsApplication(Long clientId, Long productId, String submittedOnDate) { return new PostSavingsAccountsRequest()// .clientId(clientId)// @@ -84,6 +171,13 @@ public static PostSavingsAccountsAccountIdRequest rejectSavings(String rejectedO .locale(SavingsTestData.LOCALE); } + public static PostSavingsAccountsAccountIdRequest withdrawnByApplicant(String withdrawnOnDate) { + return new PostSavingsAccountsAccountIdRequest()// + .withdrawnOnDate(withdrawnOnDate)// + .dateFormat(SavingsTestData.DATETIME_PATTERN)// + .locale(SavingsTestData.LOCALE); + } + public static PostSavingsAccountTransactionsRequest deposit(String amount, String transactionDate) { return new PostSavingsAccountTransactionsRequest()// .transactionAmount(new BigDecimal(amount))// @@ -101,4 +195,22 @@ public static PostSavingsAccountTransactionsRequest withdrawal(String amount, St .locale(SavingsTestData.LOCALE)// .paymentTypeId(1); } + + public static PostSavingsAccountTransactionsRequest postInterestAsOn(String transactionDate) { + return new PostSavingsAccountTransactionsRequest()// + .transactionDate(transactionDate)// + .postInterestManualOrAutomatic(true)// + .dateFormat(SavingsTestData.DATETIME_PATTERN)// + .locale(SavingsTestData.LOCALE); + } + + /** The server rejects a hold without a reason, and accepts no payment type on one. */ + public static PostSavingsAccountTransactionsRequest holdAmount(String amount, String transactionDate, String reasonForBlock) { + return new PostSavingsAccountTransactionsRequest()// + .transactionAmount(new BigDecimal(amount))// + .transactionDate(transactionDate)// + .reasonForBlock(reasonForBlock)// + .dateFormat(SavingsTestData.DATETIME_PATTERN)// + .locale(SavingsTestData.LOCALE); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestData.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestData.java index 187fe998e11..27f62aca3a9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestData.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestData.java @@ -22,9 +22,34 @@ public final class SavingsTestData { public static final String DATETIME_PATTERN = FeignTestConstants.DATETIME_PATTERN; public static final String LOCALE = FeignTestConstants.LOCALE; + public static final String CURRENCY_CODE = "USD"; + public static final Double DEFAULT_CHARGE_AMOUNT = 100.0; private SavingsTestData() {} + public static final class ChargeAppliesTo { + + public static final int SAVINGS = 2; + + private ChargeAppliesTo() {} + } + + public static final class ChargeTimeType { + + public static final int WITHDRAWAL_FEE = 5; + public static final int SAVINGS_ACTIVATION = 6; + public static final int ANNUAL_FEE = 7; + + private ChargeTimeType() {} + } + + public static final class ChargeCalculationType { + + public static final int FLAT = 1; + + private ChargeCalculationType() {} + } + public static final class InterestCompoundingPeriodType { public static final int DAILY = 1; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestValidators.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestValidators.java new file mode 100644 index 00000000000..4c8053ecd1c --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/SavingsTestValidators.java @@ -0,0 +1,94 @@ +/** + * 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.integrationtests.client.feign.modules; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import org.apache.fineract.client.feign.ObjectMapperFactory; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.SavingsAccountStatusEnumData; +import org.apache.fineract.client.models.SavingsAccountTransactionData; + +public final class SavingsTestValidators { + + private SavingsTestValidators() {} + + public static void verifySavingsIsPending(final SavingsAccountStatusEnumData status) { + assertNotNull(status, "Savings status is missing"); + assertTrue(Boolean.TRUE.equals(status.getSubmittedAndPendingApproval()), + "Savings account is not in pending state, was: " + status.getValue()); + } + + public static void verifySavingsIsApproved(final SavingsAccountStatusEnumData status) { + assertNotNull(status, "Savings status is missing"); + assertTrue(Boolean.TRUE.equals(status.getApproved()), "Savings account is not approved, was: " + status.getValue()); + } + + public static void verifySavingsIsActive(final SavingsAccountStatusEnumData status) { + assertNotNull(status, "Savings status is missing"); + assertTrue(Boolean.TRUE.equals(status.getActive()), "Savings account is not active, was: " + status.getValue()); + } + + public static void verifySavingsIsRejected(final SavingsAccountStatusEnumData status) { + assertNotNull(status, "Savings status is missing"); + assertTrue(Boolean.TRUE.equals(status.getRejected()), "Savings account is not rejected, was: " + status.getValue()); + } + + public static void verifySavingsIsWithdrawn(final SavingsAccountStatusEnumData status) { + assertNotNull(status, "Savings status is missing"); + assertTrue(Boolean.TRUE.equals(status.getWithdrawnByApplicant()), + "Savings account is not withdrawn by applicant, was: " + status.getValue()); + } + + public static void verifySavingsIsClosed(final SavingsAccountStatusEnumData status) { + assertNotNull(status, "Savings status is missing"); + assertTrue(Boolean.TRUE.equals(status.getClosed()), "Savings account is not closed, was: " + status.getValue()); + } + + public static void verifyIsInterestPosting(final SavingsAccountTransactionData transaction) { + assertNotNull(transaction, "Interest posting transaction is missing"); + assertNotNull(transaction.getTransactionType(), "Transaction type is missing"); + assertTrue(Boolean.TRUE.equals(transaction.getTransactionType().getInterestPosting()), + "Transaction is not an interest posting, was: " + transaction.getTransactionType().getValue()); + } + + /** Compares by value, ignoring the scale the server returns (0.274 and 0.2740 are equal). */ + public static void verifyAmount(final BigDecimal expected, final BigDecimal actual, final String message) { + assertNotNull(actual, message + " - amount is missing"); + assertEquals(0, expected.compareTo(actual), message + " - expected " + expected + " but was " + actual); + } + + /** The exception's own code is always the generic one; the specific code is on each {@code errors} entry. */ + public static void verifyFirstErrorCode(final String expectedCode, final CallFailedRuntimeException exception) { + assertNotNull(exception.getResponseBody(), "Error response carried no body"); + try { + JsonNode errors = ObjectMapperFactory.getShared().readTree(exception.getResponseBody()).get("errors"); + assertNotNull(errors, "Error response carried no errors array"); + assertTrue(errors.isArray() && !errors.isEmpty(), "Error response carried an empty errors array"); + assertEquals(expectedCode, errors.get(0).path("userMessageGlobalisationCode").asText()); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to parse the error response: " + exception.getResponseBody(), e); + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java index 734a170a24b..438465b3fdb 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java @@ -150,7 +150,7 @@ public void verifyAllDefaultGlobalConfigurations() { * integration test state using Spring Framework's integration test infrastructure for transaction commits and * rollbacks. */ - private static ArrayList getAllDefaultGlobalConfigurations() { + public static ArrayList getAllDefaultGlobalConfigurations() { ArrayList defaults = new ArrayList<>(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/batch/BatchServiceHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/batch/BatchServiceHelper.java index b61ad54d450..7cdc070a34d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/batch/BatchServiceHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/batch/BatchServiceHelper.java @@ -40,6 +40,10 @@ public List handleBatch(BatchRequest batchRequest, boolean enclos return handleBatch(List.of(batchRequest), enclosingTransaction); } + /** + * The endpoint answers 200 even when a sub-request fails or the enclosing transaction rolls back, so a failing + * batch still comes back as a response list and each sub-request's own status code has to be asserted on. + */ public List handleBatch(List batchRequests, boolean enclosingTransaction) { return FeignCalls.ok(() -> api().handleBatchRequests(batchRequests, enclosingTransaction)); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/AccountTransferWithdrawalFeeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/AccountTransferWithdrawalFeeTest.java index 821bae63082..4c27bdd0c58 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/AccountTransferWithdrawalFeeTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/AccountTransferWithdrawalFeeTest.java @@ -18,177 +18,126 @@ */ package org.apache.fineract.integrationtests.savings; -import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertNotNull; import java.math.BigDecimal; import org.apache.fineract.client.models.AccountTransferRequest; -import org.apache.fineract.client.models.ChargeRequest; import org.apache.fineract.client.models.PaymentTypeCreateRequest; import org.apache.fineract.client.models.PostChargesResponse; -import org.apache.fineract.client.models.PostSavingsAccountsResponse; -import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesRequest; -import org.apache.fineract.client.models.PostSavingsAccountsSavingsAccountIdChargesResponse; import org.apache.fineract.client.models.PostSavingsProductsRequest; -import org.apache.fineract.client.models.PostSavingsProductsResponse; -import org.apache.fineract.client.models.SavingsAccountData; -import org.apache.fineract.integrationtests.common.ClientHelper; -import org.apache.fineract.integrationtests.common.PaymentTypeHelper; +import org.apache.fineract.client.models.PutSavingsAccountsAccountIdRequest; +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.apache.fineract.integrationtests.common.Utils; -import org.apache.fineract.integrationtests.common.charges.ChargesHelper; -import org.apache.fineract.integrationtests.savings.base.BaseSavingsIntegrationTest; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -public class AccountTransferWithdrawalFeeTest extends BaseSavingsIntegrationTest { +public class AccountTransferWithdrawalFeeTest extends FeignSavingsTestBase { - private static final String ACCOUNT_TRANSFER_AMOUNT = "15000.0"; + private static final String ACCOUNT_OPENING_DATE = "01 January 2013"; private static final String TRANSFER_DATE = "01 March 2013"; + private static final String OPENING_DEPOSIT_AMOUNT = "30000"; + private static final String ACCOUNT_TRANSFER_AMOUNT = "15000.0"; + + private static final String SAVINGS_ACCOUNT_TYPE = "2"; + private static final String HEAD_OFFICE_ID = "1"; @Test public void testFromSavingsToSavingsAccountTransferWithWithdrawalFee() { - // Create withdrawal fee charge (standard withdrawal fee without payment type binding) - final ChargesHelper chargesHelper = new ChargesHelper(); - final PostChargesResponse withdrawalCharge = chargesHelper - .createCharges(new ChargeRequest().active(true).name(Utils.uniqueRandomStringGenerator("Charge_Savings_", 6)) - .currencyCode("USD").amount(100.0d).chargeAppliesTo(2).chargeTimeType(5).chargeCalculationType(1).locale("en")); - Assertions.assertNotNull(withdrawalCharge.getResourceId()); - - // Create clients - final Long fromClientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); - final Long toClientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); - - // Create savings product with withdrawalFeeForTransfers enabled - final PostSavingsProductsResponse savingsProduct = createProduct(savingsProduct()); - final Long productId = savingsProduct.getResourceId(); - - // Create FROM savings account and enable withdrawalFeeForTransfers on it - final PostSavingsAccountsResponse fromSavingsResponse = applySavingsAccount( - applySavingsRequest(fromClientId, productId, "01 January 2013")); - final Long fromSavingsId = fromSavingsResponse.getSavingsId(); - enableWithdrawalFeeForTransfers(fromSavingsId); - - approveSavingsAccount(fromSavingsId, "01 January 2013"); - activateSavingsAccount(fromSavingsId, "01 January 2013"); - deposit(fromSavingsId, "01 January 2013", BigDecimal.valueOf(30000)); - - // Add withdrawal fee charge to FROM savings account - final PostSavingsAccountsSavingsAccountIdChargesResponse chargeResponse = ok(fineractClient().savingsAccountCharges - .createSavingsAccountCharge(fromSavingsId, new PostSavingsAccountsSavingsAccountIdChargesRequest() - .chargeId(withdrawalCharge.getResourceId()).amount(100.0f).locale("en"))); - Assertions.assertNotNull(chargeResponse.getResourceId()); - - // Create TO savings account - final PostSavingsAccountsResponse toSavingsResponse = applySavingsAccount( - applySavingsRequest(toClientId, productId, "01 January 2013")); - final Long toSavingsId = toSavingsResponse.getSavingsId(); - - approveSavingsAccount(toSavingsId, "01 January 2013"); - activateSavingsAccount(toSavingsId, "01 January 2013"); - - // Perform transfer - without null-checks in SavingsAccount.payWithdrawalFee(), - // this would trigger NPE because paymentDetail is null during account transfers - ok(fineractClient().accountTransfers.createAccountTransfer(new AccountTransferRequest().fromClientId(String.valueOf(fromClientId)) - .fromAccountId(String.valueOf(fromSavingsId)).fromAccountType("2").fromOfficeId("1").toClientId(String.valueOf(toClientId)) - .toAccountId(String.valueOf(toSavingsId)).toAccountType("2").toOfficeId("1").transferDate(TRANSFER_DATE) - .transferAmount(ACCOUNT_TRANSFER_AMOUNT).transferDescription("Transfer").dateFormat(DATETIME_PATTERN).locale("en_GB"))); - - // Verify balances: charge has no payment type binding, so it applies as normal withdrawal fee - // from = 30000 - 15000 (transfer) - 100 (withdrawal fee) = 14900 - SavingsAccountData fromSavingsAccount = ok( - fineractClient().savingsAccounts.retrieveSavingsAccount(fromSavingsId, false, null, "summary")); - Assertions.assertEquals(BigDecimal.valueOf(14900).setScale(0), fromSavingsAccount.getSummary().getAccountBalance().setScale(0), - "Verifying From Savings Account Balance after Account Transfer with Withdrawal Fee"); - - // to = 0 + 15000 (transfer) = 15000 - SavingsAccountData toSavingsAccount = ok( - fineractClient().savingsAccounts.retrieveSavingsAccount(toSavingsId, false, null, "summary")); - Assertions.assertEquals(BigDecimal.valueOf(15000).setScale(0), toSavingsAccount.getSummary().getAccountBalance().setScale(0), - "Verifying To Savings Account Balance after Account Transfer"); + final PostChargesResponse withdrawalCharge = savingsChargeHelper.createWithdrawalFeeCharge(); + assertNotNull(withdrawalCharge.getResourceId()); + + final Long productId = createSavingsProduct(withdrawalFeeProduct()).getResourceId(); + + final Long fromClientId = createClient(); + final Long fromSavingsId = createAccountChargingTransferFees(fromClientId, productId, withdrawalCharge.getResourceId()); + + final Long toClientId = createClient(); + final Long toSavingsId = createApproveActivateSavings(toClientId, productId, ACCOUNT_OPENING_DATE); + + transfer(fromClientId, fromSavingsId, toClientId, toSavingsId); + + // 30000 - 15000 transferred - 100 withdrawal fee + verifyBalance("14900", fromSavingsId, "Verifying From Savings Account Balance after Account Transfer with Withdrawal Fee"); + verifyBalance("15000", toSavingsId, "Verifying To Savings Account Balance after Account Transfer"); } @Test public void testFromSavingsToSavingsAccountTransferWithPaymentTypeWithdrawalFee() { - // Create Payment Type - final PaymentTypeHelper paymentTypeHelper = new PaymentTypeHelper(); - final var paymentTypeResponse = paymentTypeHelper - .createPaymentType(new PaymentTypeCreateRequest().name("Bank Transfer " + System.currentTimeMillis()) - .description("Payment via bank transfer").isCashPayment(false).position(1L)); - final Long paymentTypeId = paymentTypeResponse.getResourceId(); - - // Create withdrawal fee charge WITH payment type binding - final ChargesHelper chargesHelper = new ChargesHelper(); - final PostChargesResponse withdrawalCharge = chargesHelper.createCharges(new ChargeRequest().active(true) - .name(Utils.uniqueRandomStringGenerator("Charge_Savings_", 6)).currencyCode("USD").amount(100.0d).chargeAppliesTo(2) - .chargeTimeType(5).chargeCalculationType(1).locale("en").enablePaymentType(true).paymentTypeId(paymentTypeId)); - Assertions.assertNotNull(withdrawalCharge.getResourceId()); - - // Create clients - final Long fromClientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); - final Long toClientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); - - // Create savings product - final PostSavingsProductsResponse savingsProduct = createProduct(savingsProduct()); - final Long productId = savingsProduct.getResourceId(); - - // Create FROM savings account with withdrawal fee enabled for transfers - final PostSavingsAccountsResponse fromSavingsResponse = applySavingsAccount( - applySavingsRequest(fromClientId, productId, "01 January 2013")); - final Long fromSavingsId = fromSavingsResponse.getSavingsId(); - enableWithdrawalFeeForTransfers(fromSavingsId); - - approveSavingsAccount(fromSavingsId, "01 January 2013"); - activateSavingsAccount(fromSavingsId, "01 January 2013"); - deposit(fromSavingsId, "01 January 2013", BigDecimal.valueOf(30000)); - - // Add payment-type withdrawal fee charge to FROM savings account - final PostSavingsAccountsSavingsAccountIdChargesResponse chargeResponse = ok(fineractClient().savingsAccountCharges - .createSavingsAccountCharge(fromSavingsId, new PostSavingsAccountsSavingsAccountIdChargesRequest() - .chargeId(withdrawalCharge.getResourceId()).amount(100.0f).locale("en"))); - Assertions.assertNotNull(chargeResponse.getResourceId()); - - // Create TO savings account - final PostSavingsAccountsResponse toSavingsResponse = applySavingsAccount( - applySavingsRequest(toClientId, productId, "01 January 2013")); - final Long toSavingsId = toSavingsResponse.getSavingsId(); - - approveSavingsAccount(toSavingsId, "01 January 2013"); - activateSavingsAccount(toSavingsId, "01 January 2013"); - - // Perform transfer - without null-checks in SavingsAccount.payWithdrawalFee(), - // this throws NPE because paymentDetail is null and code tries to call - // paymentDetail.getPaymentType().getName() - ok(fineractClient().accountTransfers.createAccountTransfer(new AccountTransferRequest().fromClientId(String.valueOf(fromClientId)) - .fromAccountId(String.valueOf(fromSavingsId)).fromAccountType("2").fromOfficeId("1").toClientId(String.valueOf(toClientId)) - .toAccountId(String.valueOf(toSavingsId)).toAccountType("2").toOfficeId("1").transferDate(TRANSFER_DATE) - .transferAmount(ACCOUNT_TRANSFER_AMOUNT).transferDescription("Transfer").dateFormat(DATETIME_PATTERN).locale("en_GB"))); - - // The payment-type fee is not applied because paymentDetail is null during transfer - // and the null-check skips the payment type matching - // from = 30000 - 15000 = 15000 - SavingsAccountData fromSavingsAccount = ok( - fineractClient().savingsAccounts.retrieveSavingsAccount(fromSavingsId, false, null, "summary")); - Assertions.assertEquals(BigDecimal.valueOf(15000).setScale(0), fromSavingsAccount.getSummary().getAccountBalance().setScale(0), + final Long paymentTypeId = paymentTypeHelper.createPaymentType(bankTransferPaymentType()).getResourceId(); + assertNotNull(paymentTypeId); + + final PostChargesResponse withdrawalCharge = savingsChargeHelper + .createCharge(SavingsRequestBuilders.savingsWithdrawalFeeCharge().enablePaymentType(true).paymentTypeId(paymentTypeId)); + assertNotNull(withdrawalCharge.getResourceId()); + + final Long productId = createSavingsProduct(withdrawalFeeProduct()).getResourceId(); + + final Long fromClientId = createClient(); + final Long fromSavingsId = createAccountChargingTransferFees(fromClientId, productId, withdrawalCharge.getResourceId()); + + final Long toClientId = createClient(); + final Long toSavingsId = createApproveActivateSavings(toClientId, productId, ACCOUNT_OPENING_DATE); + + transfer(fromClientId, fromSavingsId, toClientId, toSavingsId); + + // A transfer carries no payment detail to match the charge's payment type against, so the fee is skipped. + verifyBalance("15000", fromSavingsId, "Verifying From Savings Account Balance after Account Transfer with Payment Type Withdrawal Fee"); + verifyBalance("15000", toSavingsId, "Verifying To Savings Account Balance after Account Transfer"); + } + + /** The transfer-fee flag can only be set while the application is still pending approval. */ + private Long createAccountChargingTransferFees(final Long clientId, final Long productId, final Long chargeId) { + final Long savingsId = submitSavingsApplication(clientId, productId, ACCOUNT_OPENING_DATE).getSavingsId(); + assertNotNull(savingsId); + savingsHelper.updateSavingsAccount(savingsId, new PutSavingsAccountsAccountIdRequest().withdrawalFeeForTransfers(true)); + + approveSavings(savingsId, ACCOUNT_OPENING_DATE); + activateSavings(savingsId, ACCOUNT_OPENING_DATE); + deposit(savingsId, OPENING_DEPOSIT_AMOUNT, ACCOUNT_OPENING_DATE); + + assertNotNull(savingsChargeHelper.addChargeToSavings(savingsId, chargeId, SavingsTestData.DEFAULT_CHARGE_AMOUNT.floatValue()) + .getResourceId()); + return savingsId; + } + + private void transfer(final Long fromClientId, final Long fromSavingsId, final Long toClientId, final Long toSavingsId) { + accountTransferHelper.createAccountTransfer(new AccountTransferRequest()// + .fromClientId(String.valueOf(fromClientId))// + .fromAccountId(String.valueOf(fromSavingsId))// + .fromAccountType(SAVINGS_ACCOUNT_TYPE)// + .fromOfficeId(HEAD_OFFICE_ID)// + .toClientId(String.valueOf(toClientId))// + .toAccountId(String.valueOf(toSavingsId))// + .toAccountType(SAVINGS_ACCOUNT_TYPE)// + .toOfficeId(HEAD_OFFICE_ID)// + .transferDate(TRANSFER_DATE)// + .transferAmount(ACCOUNT_TRANSFER_AMOUNT)// + .transferDescription("Transfer")// + .dateFormat(SavingsTestData.DATETIME_PATTERN)// + .locale(SavingsTestData.LOCALE)); + } - // to = 0 + 15000 = 15000 - SavingsAccountData toSavingsAccount = ok( - fineractClient().savingsAccounts.retrieveSavingsAccount(toSavingsId, false, null, "summary")); - Assertions.assertEquals(BigDecimal.valueOf(15000).setScale(0), toSavingsAccount.getSummary().getAccountBalance().setScale(0), - "Verifying To Savings Account Balance after Account Transfer"); + private void verifyBalance(final String expected, final Long savingsId, final String message) { + SavingsTestValidators.verifyAmount(new BigDecimal(expected), savingsHelper.getSavingsSummary(savingsId).getAccountBalance(), + message); } - private void enableWithdrawalFeeForTransfers(Long savingsId) { - given().spec(requestSpec).body("{\"withdrawalFeeForTransfers\": true}").expect().spec(responseSpec).log().ifError().when() - .put("/fineract-provider/api/v1/savingsaccounts/" + savingsId + "?" + Utils.TENANT_IDENTIFIER); + private static PostSavingsProductsRequest withdrawalFeeProduct() { + return SavingsRequestBuilders + .savingsProduct(SavingsTestData.InterestCompoundingPeriodType.DAILY, SavingsTestData.InterestPostingPeriodType.MONTHLY, + SavingsTestData.InterestCalculationType.DAILY_BALANCE)// + .digitsAfterDecimal(2)// + .description("Savings product for withdrawal fee test"); } - private PostSavingsProductsRequest savingsProduct() { - return new PostSavingsProductsRequest().locale("en").name(Utils.uniqueRandomStringGenerator("SAVINGS_PRODUCT_", 6)) - .shortName(Utils.uniqueRandomStringGenerator("", 4)).description("Savings product for withdrawal fee test") - .currencyCode("USD").digitsAfterDecimal(2).inMultiplesOf(0).nominalAnnualInterestRate(10.0) - .interestCompoundingPeriodType(InterestPeriodType.DAILY).interestPostingPeriodType(InterestPeriodType.MONTHLY) - .interestCalculationType(InterestCalculationType.DAILY_BALANCE).interestCalculationDaysInYearType(DaysInYearType.DAYS_365) - .accountingRule(1).withdrawalFeeForTransfers(true).allowOverdraft(false).enforceMinRequiredBalance(false).withHoldTax(false) - .isDormancyTrackingActive(false); + private static PaymentTypeCreateRequest bankTransferPaymentType() { + return new PaymentTypeCreateRequest()// + .name(Utils.uniqueRandomStringGenerator("Bank_Transfer_", 6))// + .description("Payment via bank transfer")// + .isCashPayment(false)// + .position(1L); } }