FINERACT-1912 Refactored Implementation of Savings Account Transaction Search

FINERACT-1912 Refactored Implementation of Savings Account Transaction Search

FINERACT-1912 Refactored Implementation of Savings Account Transaction Search

FINERACT-1912 Savings Account Transaction Advanced Search Support

FINERACT-1912 Savings Account Transaction Advanced Search Support

Support Pagination, Sorting and Filtering By transaction Type, Transaction Date and Amount

Supported Filtering for Amount and Date,
GTE, LTE, GT, LT
Supported Multiple Transaction Types Filtering
This commit is contained in:
Mkkor
2023-06-13 16:37:42 +02:00
committed by Arnold Gálovics
parent 25d7a9228a
commit 5d011077e7
17 changed files with 1340 additions and 2 deletions
@@ -275,6 +275,23 @@ public class DataValidatorBuilder {
return this;
}
public DataValidatorBuilder notExceedingListLengthOf(final Integer maxLength) {
if (this.value == null && this.ignoreNullValue) {
return this;
}
if (this.value instanceof List && ((List<?>) this.value).size() > maxLength) {
final StringBuilder validationErrorCode = new StringBuilder("validation.msg.").append(this.resource).append(".")
.append(this.parameter).append(".exceeds.max.length.allowed");
final StringBuilder defaultEnglishMessage = new StringBuilder("The parameter `").append(this.parameter)
.append("` exceeds allowed max length of ").append(maxLength).append(".");
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode.toString(),
defaultEnglishMessage.toString(), this.parameter);
this.dataValidationErrors.add(error);
}
return this;
}
public DataValidatorBuilder inMinMaxRange(final Integer min, final Integer max) {
if (this.value == null && this.ignoreNullValue) {
return this;
@@ -0,0 +1,31 @@
/**
* 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.infrastructure.core.data;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum RangeOperator {
GTE(">="), LTE("<="), GT(">"), LT("<");
private final String symbol;
}
@@ -26,6 +26,7 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -34,6 +35,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
@Entity
@Table(name = "m_account_transfer_transaction")
@Getter
public class AccountTransferTransaction extends AbstractPersistableCustom {
@ManyToOne
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -48,13 +49,17 @@ import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityEx
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.core.service.PagedRequest;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionsSearchServiceImpl;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
@@ -71,6 +76,7 @@ public class SavingsAccountTransactionsApiResource {
private final ApiRequestParameterHelper apiRequestParameterHelper;
private final SavingsAccountReadPlatformService savingsAccountReadPlatformService;
private final PaymentTypeReadPlatformService paymentTypeReadPlatformService;
private final SavingsAccountTransactionsSearchServiceImpl transactionsSearchServiceImpl;
private boolean is(final String commandParam, final String commandValue) {
return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue);
@@ -118,6 +124,19 @@ public class SavingsAccountTransactionsApiResource {
SavingsApiSetConstants.SAVINGS_TRANSACTION_RESPONSE_DATA_PARAMETERS);
}
@POST
@Path("search")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Search Savings Account Transactions")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SavingsAccountTransactionsApiResourceSwagger.SavingsAccountTransactionsSearchResponse.class))) })
public String searchTransactions(@PathParam("savingsId") @Parameter(description = "savingsId") final Long savingsId,
@Parameter PagedRequest<SavingsTransactionSearch> searchRequest) {
Page<SavingsAccountTransactionData> transactionsData = transactionsSearchServiceImpl.searchTransactions(savingsId, searchRequest);
return toApiJsonSerializer.serialize(transactionsData);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -20,11 +20,172 @@ package org.apache.fineract.portfolio.savings.api;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
final class SavingsAccountTransactionsApiResourceSwagger {
private SavingsAccountTransactionsApiResourceSwagger() {}
@Schema(description = "SavingsAccountTransactionsSearchResponse")
public static final class SavingsAccountTransactionsSearchResponse {
private SavingsAccountTransactionsSearchResponse() {}
static final class GetSavingsAccountTransactionsPageItem {
private GetSavingsAccountTransactionsPageItem() {}
static final class GetTranscationEnumData {
private GetTranscationEnumData() {}
@Schema(example = "1")
public Long id;
@Schema(example = "savingsAccountTransactionType.deposit")
public String code;
@Schema(example = "Deposit")
public String value;
@Schema(example = "true")
public boolean deposit;
@Schema(example = "false")
public boolean dividendPayout;
@Schema(example = "false")
public boolean withdrawal;
@Schema(example = "false")
public boolean interestPosting;
@Schema(example = "false")
public boolean feeDeduction;
@Schema(example = "false")
public boolean initiateTransfer;
@Schema(example = "false")
public boolean approveTransfer;
@Schema(example = "false")
public boolean withdrawTransfer;
@Schema(example = "false")
public boolean rejectTransfer;
@Schema(example = "false")
public boolean overdraftInterest;
@Schema(example = "false")
public boolean writtenoff;
@Schema(example = "true")
public boolean overdraftFee;
@Schema(example = "false")
public boolean withholdTax;
@Schema(example = "false")
public boolean escheat;
@Schema(example = "false")
public boolean amountHold;
@Schema(example = "false")
public boolean amountRelease;
}
static final class GetTransactionsCurrency {
private GetTransactionsCurrency() {}
@Schema(example = "USD")
public String code;
@Schema(example = "US Dollar")
public String name;
@Schema(example = "2")
public Integer decimalPlaces;
@Schema(example = "0")
public Integer isMultiplesOf;
@Schema(example = "$")
public String displaySymbol;
@Schema(example = "currency.USD")
public String nameCode;
@Schema(example = "US Dollar ($)")
public String displayLabel;
}
static final class GetTransactionsPaymentDetailData {
private GetTransactionsPaymentDetailData() {}
static final class GetPaymentTypeData {
private GetPaymentTypeData() {}
@Schema(example = "1")
public Long id;
@Schema(example = "Money Transfer")
public String name;
@Schema(example = "false")
public Boolean isSystemDefined;
}
@Schema(example = "1")
public Long id;
public GetPaymentTypeData paymentType;
@Schema(example = "acc123")
public String accountNumber;
@Schema(example = "che123")
public String checkNumber;
@Schema(example = "rou123")
public String routingCode;
@Schema(example = "rec123")
public String receiptNumber;
@Schema(example = "ban123")
public String bankNumber;
}
static final class GetSavingsAccountChargesPaidByData {
private GetSavingsAccountChargesPaidByData() {}
@Schema(example = "1")
public Long chargeId;
@Schema(example = "0")
public BigDecimal amount;
}
@Schema(example = "1")
public Long id;
public GetTranscationEnumData transactionType;
@Schema(example = "1")
public Long accountId;
@Schema(example = "000000001")
public String accountNo;
@Schema(example = "[2023, 05, 01]")
public LocalDate date;
public GetTransactionsCurrency currency;
public GetTransactionsPaymentDetailData paymentDetailData;
@Schema(example = "500")
public BigDecimal amount;
@Schema(example = "500")
public BigDecimal runningBalance;
@Schema(example = "false")
public boolean reversed;
@Schema(example = "[2023, 05, 01]")
public LocalDate submittedOnDate;
@Schema(example = "false")
public boolean interestedPostedAsOn;
@Schema(example = "mifos")
public String submittedByUsername;
@Schema(example = "false")
public boolean isManualTransaction;
@Schema(example = "false")
public Boolean isReversal;
@Schema(example = "0")
public Long originalTransactionId;
@Schema(example = "false")
public Boolean lienTransaction;
@Schema(example = "0")
public Long releaseTransactionId;
public Set<GetSavingsAccountChargesPaidByData> chargesPaidByData = new HashSet<>();
}
@Schema(example = "2")
public Long totalFilteredRecords;
public Set<GetSavingsAccountTransactionsPageItem> pageItems;
}
@Schema(description = "PostSavingsAccountTransactionsRequest")
public static final class PostSavingsAccountTransactionsRequest {
@@ -0,0 +1,95 @@
/**
* 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.portfolio.savings.data;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.transactionAmountParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.transactionDateParamName;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.data.RangeOperator;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch.RangeFilter;
import org.springframework.stereotype.Component;
@Component
public class SavingsAccountTransactionSearchValidator {
public void validateSearchFilters(SavingsTransactionSearch.Filters searchFilters) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(SavingsApiConstants.SAVINGS_ACCOUNT_TRANSACTION_RESOURCE_NAME);
if (searchFilters != null) {
List<RangeFilter<LocalDate>> dateFilters = searchFilters.getTransactionDate();
validateRangeFilters(baseDataValidator, dateFilters, transactionDateParamName);
List<RangeFilter<BigDecimal>> amountFilters = searchFilters.getTransactionAmount();
validateRangeFilters(baseDataValidator, amountFilters, transactionAmountParamName);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private <T> void validateRangeFilters(DataValidatorBuilder baseDataValidator, List<RangeFilter<T>> rangeFilters, String paramName) {
if (rangeFilters == null) {
return;
}
if (rangeFilters.size() > 2) {
baseDataValidator.parameter(paramName).value(rangeFilters).notExceedingListLengthOf(2);
}
if (!rangeFilters.isEmpty()) {
RangeFilter<T> firstFilter = rangeFilters.get(0);
RangeOperator firstOperator = firstFilter.getOperator();
if (rangeFilters.size() == 2) {
RangeFilter<T> secondFilter = rangeFilters.get(1);
RangeOperator secondOperator = secondFilter.getOperator();
if (((firstOperator == RangeOperator.GT || firstOperator == RangeOperator.GTE)
&& !(secondOperator == RangeOperator.LT || secondOperator == RangeOperator.LTE))
|| ((firstOperator == RangeOperator.LT || firstOperator == RangeOperator.LTE)
&& !(secondOperator == RangeOperator.GT || secondOperator == RangeOperator.GTE))) {
baseDataValidator.parameter(paramName).failWithCode("invalid.range", firstOperator, secondOperator);
}
}
}
for (RangeFilter<T> filter : rangeFilters) {
T value = filter.getValue();
if (value instanceof BigDecimal) {
baseDataValidator.parameter(paramName).value(value).zeroOrPositiveAmount();
}
}
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -0,0 +1,147 @@
/**
* 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.portfolio.savings.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
import org.apache.fineract.portfolio.account.data.AccountTransferData;
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
import org.apache.fineract.useradministration.domain.AppUser;
@Getter
@AllArgsConstructor
public class SavingsTransactionSearchResult {
private Long transactionId;
private Integer transactionType;
private LocalDate transactionDate;
private BigDecimal transactionAmount;
private Long releaseIdOfHoldAmountTransaction;
private String reasonForBlock;
private LocalDateTime createdDate;
private AppUser appUser;
private String note;
private BigDecimal runningBalance;
private boolean reversed;
private boolean reversalTransaction;
private Long originalTxnId;
private Boolean lienTransaction;
private boolean isManualTransaction;
private AccountTransferTransaction fromSavingsTransaction;
private AccountTransferTransaction toSavingsTransaction;
private SavingsAccount savingsAccount;
private PaymentDetail paymentDetail;
private ApplicationCurrency currency;
public static final SavingsAccountTransactionData toSavingsAccountTransactionData(SavingsTransactionSearchResult dto) {
final Long id = dto.getTransactionId();
final int transactionTypeInt = dto.getTransactionType();
final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt);
final LocalDate date = dto.getTransactionDate();
final LocalDate submittedOnDate = Optional.ofNullable(dto.getCreatedDate()).map(LocalDateTime::toLocalDate).orElse(null);
final BigDecimal amount = Optional.ofNullable(dto.getTransactionAmount()).orElse(BigDecimal.ZERO);
final Long releaseTransactionId = Optional.ofNullable(dto.getReleaseIdOfHoldAmountTransaction()).orElse(null);
final String reasonForBlock = Optional.ofNullable(dto.getReasonForBlock()).orElse(null);
final BigDecimal outstandingChargeAmount = null;
final BigDecimal runningBalance = Optional.ofNullable(dto.getRunningBalance()).orElse(BigDecimal.ZERO);
final boolean reversed = dto.isReversed();
final boolean isReversal = dto.isReversalTransaction();
final Long originalTransactionId = Optional.ofNullable(dto.getOriginalTxnId()).orElse(null);
final Boolean lienTransaction = dto.getLienTransaction();
final Long savingsId = Optional.ofNullable(dto.getSavingsAccount()).map(savingsAccount -> savingsAccount.getId()).orElse(null);
final String accountNo = Optional.ofNullable(dto.getSavingsAccount()).map(savingsAccount -> savingsAccount.getAccountNumber())
.orElse(null);
final boolean postInterestAsOn = dto.isManualTransaction();
PaymentDetailData paymentDetailData = null;
if (Objects.nonNull(transactionType) && transactionType.isDepositOrWithdrawal()) {
final PaymentDetail paymentDetail = dto.getPaymentDetail();
if (Objects.nonNull(paymentDetail)) {
final Long paymentTypeId = Optional.ofNullable(paymentDetail.getPaymentType()).map(paymentType -> paymentType.getId())
.orElse(null);
if (Objects.nonNull(paymentTypeId)) {
final String typeName = Optional.ofNullable(paymentDetail.getPaymentType()).map(paymentType -> paymentType.getName())
.orElse(null);
final PaymentTypeData paymentType = PaymentTypeData.instance(paymentTypeId, typeName);
final String accountNumber = paymentDetail.getAccountNumber();
final String checkNumber = paymentDetail.getCheckNumber();
final String routingCode = paymentDetail.getRoutingCode();
final String receiptNumber = paymentDetail.getReceiptNumber();
final String bankNumber = paymentDetail.getBankNumber();
paymentDetailData = new PaymentDetailData(id, paymentType, accountNumber, checkNumber, routingCode, receiptNumber,
bankNumber);
}
}
}
final String currencyCode = dto.getSavingsAccount().getCurrency().getCode();
final String currencyName = dto.getCurrency().getName();
final String currencyNameCode = dto.getCurrency().getNameCode();
final String currencyDisplaySymbol = dto.getCurrency().getDisplaySymbol();
final Integer currencyDigits = dto.getSavingsAccount().getCurrency().getDigitsAfterDecimal();
final Integer inMultiplesOf = dto.getSavingsAccount().getCurrency().getCurrencyInMultiplesOf();
final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol,
currencyNameCode);
AccountTransferData transfer = null;
AccountTransferTransaction transferFrom = dto.getFromSavingsTransaction();
AccountTransferTransaction transferTo = dto.getToSavingsTransaction();
if (Objects.nonNull(transferFrom)) {
final Long fromTransferId = transferFrom.getId();
final LocalDate fromTransferDate = transferFrom.getDate();
final BigDecimal fromTransferAmount = Optional.ofNullable(transferFrom.getAmount()).orElse(BigDecimal.ZERO);
final boolean fromTransferReversed = transferFrom.isReversed();
final String fromTransferDescription = transferFrom.getDescription();
transfer = AccountTransferData.transferBasicDetails(fromTransferId, currency, fromTransferAmount, fromTransferDate,
fromTransferDescription, fromTransferReversed);
} else if (Objects.nonNull(transferTo)) {
final Long toTransferId = transferTo.getId();
final LocalDate toTransferDate = transferTo.getDate();
final BigDecimal toTransferAmount = Optional.ofNullable(transferTo.getAmount()).orElse(BigDecimal.ZERO);
final boolean toTransferReversed = transferTo.isReversed();
final String toTransferDescription = transferTo.getDescription();
transfer = AccountTransferData.transferBasicDetails(toTransferId, currency, toTransferAmount, toTransferDate,
toTransferDescription, toTransferReversed);
}
final String submittedByUsername = Optional.ofNullable(dto.getAppUser()).map(user -> user.getUsername()).orElse(null);
final String note = Optional.ofNullable(dto.getNote()).orElse(null);
return SavingsAccountTransactionData.create(id, transactionType, paymentDetailData, savingsId, accountNo, date, currency, amount,
outstandingChargeAmount, runningBalance, reversed, transfer, submittedOnDate, postInterestAsOn, submittedByUsername, note,
isReversal, originalTransactionId, lienTransaction, releaseTransactionId, reasonForBlock);
}
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.LockModeType;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionsSearchRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -28,8 +29,8 @@ import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface SavingsAccountTransactionRepository
extends JpaRepository<SavingsAccountTransaction, Long>, JpaSpecificationExecutor<SavingsAccountTransaction> {
public interface SavingsAccountTransactionRepository extends JpaRepository<SavingsAccountTransaction, Long>,
JpaSpecificationExecutor<SavingsAccountTransaction>, SavingsTransactionsSearchRepository {
@Query("select sat from SavingsAccountTransaction sat where sat.id = :transactionId and sat.savingsAccount.id = :savingsId")
SavingsAccountTransaction findOneByIdAndSavingsAccountId(@Param("transactionId") Long transactionId,
@@ -0,0 +1,29 @@
/**
* 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.portfolio.savings.domain.search;
import java.util.Map;
import lombok.Data;
@Data
public class BaseQueryParametersMapResult {
private final String baseQueryString;
private final Map<String, Object> parametersMap;
}
@@ -0,0 +1,51 @@
/**
* 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.portfolio.savings.domain.search;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.Data;
import org.apache.fineract.infrastructure.core.data.RangeOperator;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
@Data
public class SavingsTransactionSearch {
private Filters filters;
@Data
public static class Filters {
private List<RangeFilter<LocalDate>> transactionDate;
private List<RangeFilter<BigDecimal>> transactionAmount;
private List<SavingsAccountTransactionType> transactionType;
}
@Data
public static class RangeFilter<T> {
private RangeOperator operator;
private T value;
}
}
@@ -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.portfolio.savings.domain.search;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch.Filters;
import org.springframework.data.domain.Pageable;
@Getter
@Builder
public class SavingsTransactionSearchParameters {
private Long savingsId;
private DepositAccountType depositAccountType;
private Filters filters;
private Pageable pageable;
}
@@ -0,0 +1,27 @@
/**
* 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.portfolio.savings.domain.search;
import org.apache.fineract.portfolio.savings.data.SavingsTransactionSearchResult;
import org.springframework.data.domain.Page;
public interface SavingsTransactionsSearchRepository {
Page<SavingsTransactionSearchResult> searchTransactions(SavingsTransactionSearchParameters searchParameters);
}
@@ -0,0 +1,186 @@
/**
* 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.portfolio.savings.domain.search;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.data.SavingsTransactionSearchResult;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch.Filters;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch.RangeFilter;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class SavingsTransactionsSearchRepositoryImpl implements SavingsTransactionsSearchRepository {
private static final String AMOUNT_FIELD_NAME = "amount";
private static final String ID_FIELD_NAME = "id";
private static final String CREATED_DATE_FIELD_NAME = "createdDate";
private static final String TRANSACTION_DATE_FIELD_NAME = "dateOf";
private final EntityManager entityManager;
@Override
public Page<SavingsTransactionSearchResult> searchTransactions(SavingsTransactionSearchParameters searchParameters) {
// Build base query with filters but without the selection
BaseQueryParametersMapResult baseQueryParameterMapResult = buildBaseQueryWithFilters(searchParameters.getSavingsId(),
searchParameters.getDepositAccountType(), searchParameters.getFilters());
// Attach the selection
String jpqlQuery = attachSelection(baseQueryParameterMapResult.getBaseQueryString());
// Attach the ordering
String queryWithOrdering = attachOrdering(jpqlQuery, searchParameters.getPageable().getSort());
// Execute Query
TypedQuery<SavingsTransactionSearchResult> queryToExecute = entityManager.createQuery(queryWithOrdering,
SavingsTransactionSearchResult.class);
setQueryParameters(queryToExecute, baseQueryParameterMapResult.getParametersMap());
applyPagination(queryToExecute, searchParameters.getPageable());
List<SavingsTransactionSearchResult> resultList = queryToExecute.getResultList();
// Attach the count selection
String countQuery = attachCountSelection(baseQueryParameterMapResult.getBaseQueryString());
// Execute count query
TypedQuery<Long> countQueryToExecute = entityManager.createQuery(countQuery, Long.class);
setQueryParameters(countQueryToExecute, baseQueryParameterMapResult.getParametersMap());
Long totalElements = countQueryToExecute.getSingleResult();
return PageableExecutionUtils.getPage(resultList, searchParameters.getPageable(), () -> totalElements);
}
private <T> void setQueryParameters(TypedQuery<T> queryToExecute, Map<String, Object> parametersMap) {
for (Map.Entry<String, Object> entry : parametersMap.entrySet()) {
queryToExecute.setParameter(entry.getKey(), entry.getValue());
}
}
private BaseQueryParametersMapResult buildBaseQueryWithFilters(Long savingsId, DepositAccountType depositAccountType, Filters filters) {
String baseQuery = """
SELECT tr
FROM SavingsAccountTransaction tr
JOIN ApplicationCurrency currency ON (currency.code = tr.savingsAccount.currency.code)
LEFT JOIN AccountTransferTransaction fromtran ON (fromtran.fromSavingsTransaction = tr)
LEFT JOIN AccountTransferTransaction totran ON (totran.toSavingsTransaction = tr)
LEFT JOIN tr.notes nt ON (nt.savingsTransaction = tr)
WHERE tr.savingsAccount.id = :savingsId
AND tr.savingsAccount.depositType = :depositType
""";
StringBuilder baseQueryBuilder = new StringBuilder(baseQuery);
Map<String, Object> parameterMap = new HashMap<>();
parameterMap.put("savingsId", savingsId);
parameterMap.put("depositType", depositAccountType.getValue());
setFilterConditions(baseQueryBuilder, parameterMap, filters);
return new BaseQueryParametersMapResult(baseQueryBuilder.toString(), parameterMap);
}
private String attachSelection(String baseQuery) {
return baseQuery.replace("SELECT tr",
"SELECT NEW org.apache.fineract.portfolio.savings.data.SavingsTransactionSearchResult(tr.id,tr.typeOf, tr.dateOf, tr.amount, tr.releaseIdOfHoldAmountTransaction, tr.reasonForBlock,tr.createdDate, tr.appUser, nt.note, tr.runningBalance, tr.reversed,tr.reversalTransaction, tr.originalTxnId, tr.lienTransaction, tr.isManualTransaction,fromTran, toTran, tr.savingsAccount, tr.paymentDetail, currency) ");
}
private String attachOrdering(String jpqlQuery, Sort sort) {
StringJoiner orderByClauseBuilder = new StringJoiner(", ", " ORDER BY ", "");
if (Objects.nonNull(sort) && sort.isSorted()) {
buildOrderByClause(sort.toList(), orderByClauseBuilder);
} else {
List<Order> defaultOrders = getDefaultOrders();
buildOrderByClause(defaultOrders, orderByClauseBuilder);
}
return new StringBuilder(jpqlQuery).append(orderByClauseBuilder.toString()).toString();
}
private void buildOrderByClause(List<Order> orders, StringJoiner orderByClauseBuilder) {
for (Order order : orders) {
String property = "tr." + order.getProperty();
String direction = order.getDirection().name();
String orderByExpression = new StringBuilder(property).append(StringUtils.SPACE).append(direction).toString();
orderByClauseBuilder.add(orderByExpression);
}
}
private List<Order> getDefaultOrders() {
return List.of(Order.desc(TRANSACTION_DATE_FIELD_NAME), Order.desc(CREATED_DATE_FIELD_NAME), Order.desc(ID_FIELD_NAME));
}
private void applyPagination(TypedQuery<?> query, Pageable pageable) {
if (pageable.isPaged()) {
query.setFirstResult((int) pageable.getOffset());
query.setMaxResults(pageable.getPageSize());
}
}
private String attachCountSelection(String baseQuery) {
return baseQuery.replace("SELECT tr", "SELECT COUNT(tr) ");
}
private void setFilterConditions(StringBuilder queryBuilder, Map<String, Object> parameterMap, Filters filters) {
if (Objects.nonNull(filters)) {
List<RangeFilter<LocalDate>> dateFilters = filters.getTransactionDate();
List<RangeFilter<BigDecimal>> amountFilters = filters.getTransactionAmount();
List<SavingsAccountTransactionType> transactionTypes = filters.getTransactionType();
if (Objects.nonNull(dateFilters)) {
processRangeFilters(queryBuilder, parameterMap, dateFilters, TRANSACTION_DATE_FIELD_NAME);
}
if (Objects.nonNull(amountFilters)) {
processRangeFilters(queryBuilder, parameterMap, amountFilters, AMOUNT_FIELD_NAME);
}
if (CollectionUtils.isNotEmpty(transactionTypes)) {
List<Integer> transactionTypeValues = transactionTypes.stream().map(SavingsAccountTransactionType::getValue)
.collect(Collectors.toList());
queryBuilder.append(" AND tr.typeOf IN :transactionTypes ");
parameterMap.put("transactionTypes", transactionTypeValues);
}
}
}
private <T> void processRangeFilters(StringBuilder queryBuilder, Map<String, Object> parameterMap, List<RangeFilter<T>> filters,
String field) {
filters.forEach(filter -> {
String paramName = new StringBuilder(field).append(filter.getOperator()).toString();
queryBuilder.append(" AND tr.").append(field).append(StringUtils.SPACE).append(filter.getOperator().getSymbol()).append(" :")
.append(paramName);
parameterMap.put(paramName, filter.getValue());
});
}
}
@@ -0,0 +1,30 @@
/**
* 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.portfolio.savings.service.search;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.core.service.PagedRequest;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch;
public interface SavingsAccountTransactionSearchService {
Page<SavingsAccountTransactionData> searchTransactions(Long savingsId, PagedRequest<SavingsTransactionSearch> searchRequest);
}
@@ -0,0 +1,76 @@
/**
* 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.portfolio.savings.service.search;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.core.service.PagedRequest;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionSearchValidator;
import org.apache.fineract.portfolio.savings.data.SavingsTransactionSearchResult;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearch.Filters;
import org.apache.fineract.portfolio.savings.domain.search.SavingsTransactionSearchParameters;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class SavingsAccountTransactionsSearchServiceImpl implements SavingsAccountTransactionSearchService {
private final PlatformSecurityContext context;
private final SavingsAccountTransactionRepository savingsTransactionRepository;
private final SavingsAccountTransactionSearchValidator searchValidator;
@Override
public Page<SavingsAccountTransactionData> searchTransactions(Long savingsId, PagedRequest<SavingsTransactionSearch> searchRequest) {
validateSearchRequest(searchRequest);
return executeSearch(savingsId, DepositAccountType.SAVINGS_DEPOSIT, searchRequest);
}
private void validateSearchRequest(PagedRequest<SavingsTransactionSearch> searchRequest) {
Objects.requireNonNull(searchRequest, "searchRequest must not be null");
context.isAuthenticated();
Optional<SavingsTransactionSearch> request = searchRequest.getRequest();
Filters searchFilters = request.map(SavingsTransactionSearch::getFilters).orElse(null);
searchValidator.validateSearchFilters(searchFilters);
}
private Page<SavingsAccountTransactionData> executeSearch(Long savingsId, DepositAccountType depositType,
PagedRequest<SavingsTransactionSearch> searchRequest) {
Optional<SavingsTransactionSearch> request = searchRequest.getRequest();
Pageable pageable = searchRequest.toPageable();
Filters searchFilters = request.map(SavingsTransactionSearch::getFilters).orElse(null);
SavingsTransactionSearchParameters searchParameters = SavingsTransactionSearchParameters.builder().savingsId(savingsId)
.depositAccountType(DepositAccountType.SAVINGS_DEPOSIT).filters(searchFilters).pageable(pageable).build();
org.springframework.data.domain.Page<SavingsAccountTransactionData> pageResult = savingsTransactionRepository
.searchTransactions(searchParameters).map(SavingsTransactionSearchResult::toSavingsAccountTransactionData);
return new Page<>(pageResult.getContent(), Long.valueOf(pageResult.getTotalElements()).intValue());
}
}
@@ -0,0 +1,414 @@
/**
* 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;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import org.apache.fineract.client.models.Filters;
import org.apache.fineract.client.models.GetSavingsAccountTransactionsPageItem;
import org.apache.fineract.client.models.PagedRequestSavingsTransactionSearch;
import org.apache.fineract.client.models.RangeFilterBigDecimal;
import org.apache.fineract.client.models.RangeFilterLocalDate;
import org.apache.fineract.client.models.SavingsAccountTransactionsSearchResponse;
import org.apache.fineract.client.models.SavingsTransactionSearch;
import org.apache.fineract.client.models.SortOrder;
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.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@SuppressWarnings({ "rawtypes" })
public class SavingsAccountTransactionsSearchIntegrationTest {
public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL";
public static final String DEFAULT_DATE_FORMAT = "dd MMM yyyy";
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;
@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.responseSpecForValidationError = new ResponseSpecBuilder().expectStatusCode(400).build();
this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
this.savingsAccountHelperValidationError = new SavingsAccountHelper(this.requestSpec, this.responseSpecForValidationError);
this.savingsProductHelper = new SavingsProductHelper();
}
@Test
public void testSavingsTransactionsSearchWithAmountFilterLteGte() throws JsonProcessingException {
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate);
Assertions.assertNotNull(clientID);
final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate);
this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", startDate, CommonConstants.RESPONSE_RESOURCE_ID);
this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", startDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.GTE, BigDecimal.valueOf(100)));
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.LTE, BigDecimal.valueOf(200)));
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, null);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(1, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
BigDecimal expectedAmount = BigDecimal.valueOf(100);
assertEquals(0, expectedAmount.compareTo(pageItemsList.get(0).getAmount()));
}
@Test
public void testSavingsTransactionsSearchWithAmountFilterLtGt() throws JsonProcessingException {
final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate);
Assertions.assertNotNull(clientID);
final Integer savingsId = createSavingsAccountDailyPosting(clientID, startDate);
this.savingsAccountHelper.depositToSavingsAccount(savingsId, "100", startDate, CommonConstants.RESPONSE_RESOURCE_ID);
this.savingsAccountHelper.depositToSavingsAccount(savingsId, "300", startDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.GT, BigDecimal.valueOf(100)));
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.LT, BigDecimal.valueOf(400)));
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, null);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(1, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
BigDecimal expectedAmount = BigDecimal.valueOf(300);
assertEquals(0, expectedAmount.compareTo(pageItemsList.get(0).getAmount()));
}
@Test
public void testSavingsTransactionsSearchWithDateFilterLteGte() throws JsonProcessingException {
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, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.GTE, LocalDate.of(2023, 05, 06)));
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.LTE, LocalDate.of(2023, 05, 10)));
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, null);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(2, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
assertEquals(2, transactionsResponse.getPageItems().size());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
assertEquals(0, parseDate(withdrawDate, DEFAULT_DATE_FORMAT).compareTo(pageItemsList.get(0).getDate()));
assertEquals(0, parseDate(secondDepositDate, DEFAULT_DATE_FORMAT).compareTo(pageItemsList.get(1).getDate()));
}
@Test
public void testSavingsTransactionsSearchWithTransactionTypeDepositAndDefaultSort() {
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, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
filters.setTransactionType(List.of(Filters.TransactionTypeEnum.DEPOSIT));
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, null);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(2, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
assertEquals(2, transactionsResponse.getPageItems().size());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(0).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(300).compareTo(pageItemsList.get(0).getAmount()));
assertEquals(0, parseDate(secondDepositDate, DEFAULT_DATE_FORMAT).compareTo(pageItemsList.get(0).getDate()));
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(1).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(100).compareTo(pageItemsList.get(1).getAmount()));
assertEquals(0, parseDate(firstDepositDate, DEFAULT_DATE_FORMAT).compareTo(pageItemsList.get(1).getDate()));
}
@Test
public void testSavingsTransactionsSearchWithTransactionTypeWithdrawAndDeposit() throws JsonProcessingException {
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, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
filters.setTransactionType(List.of(Filters.TransactionTypeEnum.DEPOSIT, Filters.TransactionTypeEnum.WITHDRAWAL));
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, null);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(3, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
assertEquals(3, transactionsResponse.getPageItems().size());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
assertTrue(
Filters.TransactionTypeEnum.WITHDRAWAL.getValue().equalsIgnoreCase(pageItemsList.get(0).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(100).compareTo(pageItemsList.get(0).getAmount()));
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(1).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(300).compareTo(pageItemsList.get(1).getAmount()));
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(2).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(100).compareTo(pageItemsList.get(2).getAmount()));
}
@Test
public void testSavingsTransactionsSearchWithPaginationAndNoFilter() {
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, "100", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
int page = 0;
int size = 2;
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, page, size, null);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(3, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
assertEquals(2, transactionsResponse.getPageItems().size());
}
@Test
public void testSavingsTransactionsSearchWithTransactionTypeDepositAndSortByAmountAsc() {
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, "200", withdrawDate, CommonConstants.RESPONSE_RESOURCE_ID);
Filters filters = new Filters();
filters.setTransactionType(List.of(Filters.TransactionTypeEnum.DEPOSIT));
SortOrder sortOrder = new SortOrder();
sortOrder.setProperty("amount");
sortOrder.setDirection(SortOrder.DirectionEnum.ASC);
List<SortOrder> sortOrders = List.of(sortOrder);
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, sortOrders);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(2, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
assertEquals(2, transactionsResponse.getPageItems().size());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(0).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(100).compareTo(pageItemsList.get(0).getAmount()));
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(1).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(300).compareTo(pageItemsList.get(1).getAmount()));
}
@Test
public void testSavingsTransactionsSearchWithFiltersSortingAndPagination() {
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);
this.savingsAccountHelper.postInterestForSavings(savingsId);
Filters filters = new Filters();
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.GTE, BigDecimal.valueOf(100)));
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.LT, BigDecimal.valueOf(500)));
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.GT, LocalDate.of(2023, 05, 06)));
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.LTE, LocalDate.of(2023, 06, 01)));
filters.setTransactionType(List.of(Filters.TransactionTypeEnum.DEPOSIT));
SortOrder sortOrder = new SortOrder();
sortOrder.setProperty("amount");
sortOrder.setDirection(SortOrder.DirectionEnum.DESC);
List<SortOrder> sortOrders = List.of(sortOrder);
int page = 0;
int size = 2;
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, page, size, sortOrders);
SavingsAccountTransactionsSearchResponse transactionsResponse = this.savingsAccountHelper.searchTransactions(savingsId,
searchRequest);
Assertions.assertNotNull(transactionsResponse);
assertEquals(3, transactionsResponse.getTotalFilteredRecords());
Assertions.assertNotNull(transactionsResponse.getPageItems());
assertEquals(2, transactionsResponse.getPageItems().size());
List<GetSavingsAccountTransactionsPageItem> pageItemsList = List.copyOf(transactionsResponse.getPageItems());
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(0).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(400).compareTo(pageItemsList.get(0).getAmount()));
assertEquals(0, parseDate(thirdDepositDate, DEFAULT_DATE_FORMAT).compareTo(pageItemsList.get(0).getDate()));
assertTrue(Filters.TransactionTypeEnum.DEPOSIT.getValue().equalsIgnoreCase(pageItemsList.get(1).getTransactionType().getValue()));
assertEquals(0, BigDecimal.valueOf(300).compareTo(pageItemsList.get(1).getAmount()));
assertEquals(0, parseDate(secondDepositDate, DEFAULT_DATE_FORMAT).compareTo(pageItemsList.get(1).getDate()));
}
@Test
public void testSavingsTransactionsSearchFilterRangeValidationError() {
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);
Filters filters = new Filters();
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.GTE, BigDecimal.valueOf(100)));
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.LT, BigDecimal.valueOf(500)));
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.LT, BigDecimal.valueOf(1000)));
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.GT, LocalDate.of(2023, 05, 6)));
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.LTE, LocalDate.of(2023, 06, 1)));
filters.addTransactionDateItem(buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum.LTE, LocalDate.of(2023, 06, 8)));
filters.setTransactionType(List.of(Filters.TransactionTypeEnum.DEPOSIT));
SortOrder sortOrder = new SortOrder();
sortOrder.setProperty("amount");
sortOrder.setDirection(SortOrder.DirectionEnum.DESC);
List<SortOrder> sortOrders = List.of(sortOrder);
int page = 0;
int size = 2;
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, page, size, sortOrders);
this.savingsAccountHelperValidationError.searchTransactions(savingsId, searchRequest);
}
@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);
Filters filters = new Filters();
filters.addTransactionAmountItem(buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum.GTE, BigDecimal.valueOf(-100)));
filters.setTransactionType(List.of(Filters.TransactionTypeEnum.DEPOSIT));
PagedRequestSavingsTransactionSearch searchRequest = buildTransactionsSearchReqeust(filters, null, null, null);
this.savingsAccountHelperValidationError.searchTransactions(savingsId, searchRequest);
}
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 Integer createSavingsProductDailyPosting() {
final String savingsProductJSON = this.savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily()
.withInterestPostingPeriodTypeAsDaily().withInterestCalculationPeriodTypeAsDailyBalance().build();
return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec);
}
private PagedRequestSavingsTransactionSearch buildTransactionsSearchReqeust(Filters filters, Integer page, Integer size,
List<SortOrder> sorts) {
final Integer DEFAULT_PAGE_SIZE = 50;
SavingsTransactionSearch savingsTransactionSearch = new SavingsTransactionSearch();
savingsTransactionSearch.setFilters(filters);
PagedRequestSavingsTransactionSearch pagedRequest = new PagedRequestSavingsTransactionSearch();
pagedRequest.setRequest(savingsTransactionSearch);
pagedRequest.setSorts(sorts != null ? sorts : new ArrayList<>());
pagedRequest.setPage(page != null ? page : 0);
pagedRequest.setSize(size != null ? size : DEFAULT_PAGE_SIZE);
return pagedRequest;
}
private RangeFilterBigDecimal buildTransactionAmountRange(RangeFilterBigDecimal.OperatorEnum operator, BigDecimal value) {
RangeFilterBigDecimal transactionAmountFilter = new RangeFilterBigDecimal();
transactionAmountFilter.setOperator(operator);
transactionAmountFilter.setValue(value);
return transactionAmountFilter;
}
private RangeFilterLocalDate buildTransactionDateRange(RangeFilterLocalDate.OperatorEnum operator, LocalDate value) {
RangeFilterLocalDate transactionDateFilter = new RangeFilterLocalDate();
transactionDateFilter.setOperator(operator);
transactionDateFilter.setValue(value);
return transactionDateFilter;
}
public static LocalDate parseDate(String dateStr, String pattern) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
return LocalDate.parse(dateStr, formatter);
}
// Reset configuration fields
@AfterEach
public void tearDown() {
GlobalConfigurationHelper.resetAllDefaultGlobalConfigurations(this.requestSpec, this.responseSpec);
GlobalConfigurationHelper.verifyAllDefaultGlobalConfigurations(this.requestSpec, this.responseSpec);
}
}
@@ -36,6 +36,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.apache.fineract.client.models.PagedRequestSavingsTransactionSearch;
import org.apache.fineract.client.models.SavingsAccountTransactionsSearchResponse;
import org.apache.fineract.client.util.JSON;
import org.apache.fineract.integrationtests.common.CommonConstants;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
@@ -49,6 +52,7 @@ public class SavingsAccountHelper {
private final RequestSpecification requestSpec;
private final ResponseSpecification responseSpec;
private static final Gson GSON = new JSON().getGson();
private static final Logger LOG = LoggerFactory.getLogger(SavingsAccountHelper.class);
private static final String SAVINGS_ACCOUNT_URL = "/fineract-provider/api/v1/savingsaccounts";
@@ -646,6 +650,15 @@ public class SavingsAccountHelper {
return Utils.performServerGet(requestSpec, responseSpec, URL, "");
}
public SavingsAccountTransactionsSearchResponse searchTransactions(Integer savingsId,
PagedRequestSavingsTransactionSearch searchReqeust) {
final String SAVINGS_TRANSACTIONS_SEARCH_URL = SAVINGS_ACCOUNT_URL + "/" + savingsId + "/transactions/search" + "?"
+ Utils.TENANT_IDENTIFIER;
String jsonBodyToSend = GSON.toJson(searchReqeust);
String response = Utils.performServerPost(this.requestSpec, this.responseSpec, SAVINGS_TRANSACTIONS_SEARCH_URL, jsonBodyToSend);
return GSON.fromJson(response, SavingsAccountTransactionsSearchResponse.class);
}
public List<HashMap> getSavingsTransactions(final Integer savingsID) {
final Object get = getSavingsCollectionAttribute(savingsID, "transactions");
final String json = new Gson().toJson(get);