From 872f97b6b6d3427a5cb6093e5a32f2761513f85e Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Wed, 9 Apr 2025 08:34:02 +0200 Subject: [PATCH] FINERACT-2258: Fix for making the Post Interest for Savings job properly work in a multitenant environment --- .../legacy/JsonLocalDateArrayFormat.java | 28 ++ ...SavingsAccountApplicationTimelineData.java | 2 + .../data/SavingsAccountChargeData.java | 2 + .../savings/data/SavingsAccountData.java | 24 +- .../data/SavingsAccountSummaryData.java | 2 + .../data/SavingsAccountTransactionData.java | 2 + .../GetSavingsAccountByIdCommandStrategy.java | 19 +- .../jersey/JerseyJacksonConverterConfig.java | 15 +- .../converter/LocalDateJsonConverter.java | 3 +- ...acksonLocalDateBeanSerializerModifier.java | 59 +++++ .../legacy/JacksonLocalDateArrayModule.java | 29 ++ .../JacksonLocalDateArraySerializer.java | 41 +++ .../api/SavingsAccountsApiResource.java | 44 +--- .../PostInterestForSavingTasklet.java | 6 +- .../savings/api/SelfSavingsApiResource.java | 13 +- .../savings/api/SavingsApiSetConstants.java | 22 +- .../SavingsSchedularInterestPoster.java | 3 +- .../SavingsAccountsExternalIdTest.java | 15 +- .../savings/SavingsInterestPostingTest.java | 108 ++++++++ .../base/BaseSavingsIntegrationTest.java | 249 ++++++++++++++++++ 20 files changed, 611 insertions(+), 75 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JsonLocalDateArrayFormat.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/JacksonLocalDateBeanSerializerModifier.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArrayModule.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArraySerializer.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/SavingsInterestPostingTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/base/BaseSavingsIntegrationTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JsonLocalDateArrayFormat.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JsonLocalDateArrayFormat.java new file mode 100644 index 000000000..7d1578af5 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JsonLocalDateArrayFormat.java @@ -0,0 +1,28 @@ +/** + * 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.jersey.serializer.legacy; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface JsonLocalDateArrayFormat {} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountApplicationTimelineData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountApplicationTimelineData.java index 592baeaff..2780359b8 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountApplicationTimelineData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountApplicationTimelineData.java @@ -22,12 +22,14 @@ import java.io.Serializable; import java.time.LocalDate; import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JsonLocalDateArrayFormat; /** * Immutable data object represent the important time-line events of a savings account application. */ @Getter @RequiredArgsConstructor +@JsonLocalDateArrayFormat public class SavingsAccountApplicationTimelineData implements Serializable { private final LocalDate submittedOnDate; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountChargeData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountChargeData.java index cd80bcc37..a6abde2e5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountChargeData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountChargeData.java @@ -25,6 +25,7 @@ import java.time.MonthDay; import java.util.Collection; import lombok.Getter; import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JsonLocalDateArrayFormat; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.portfolio.charge.data.ChargeData; import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; @@ -33,6 +34,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; * Immutable data object for Savings Account charge data. */ @Getter +@JsonLocalDateArrayFormat public class SavingsAccountChargeData implements Serializable { private final Long id; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java index 1fcc9764c..e1394661c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java @@ -32,6 +32,7 @@ import lombok.Getter; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JsonLocalDateArrayFormat; import org.apache.fineract.infrastructure.dataqueries.data.DatatableData; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.staff.data.StaffData; @@ -47,6 +48,7 @@ import org.apache.fineract.portfolio.tax.data.TaxGroupData; * Immutable data object representing a savings account. */ @Getter +@JsonLocalDateArrayFormat public final class SavingsAccountData implements Serializable { private final Long id; @@ -302,23 +304,23 @@ public final class SavingsAccountData implements Serializable { } public Integer getInterestPostingPeriodTypeId() { - return this.interestPostingPeriodType.getId().intValue(); + return this.interestPostingPeriodType != null ? this.interestPostingPeriodType.getId().intValue() : null; } public Integer getDepositTypeId() { - return this.depositType.getId().intValue(); + return this.depositType != null ? this.depositType.getId().intValue() : null; } public Integer getInterestCompoundingPeriodTypeId() { - return this.interestCompoundingPeriodType.getId().intValue(); + return this.interestCompoundingPeriodType != null ? this.interestCompoundingPeriodType.getId().intValue() : null; } public Integer getInterestCalculationTypeId() { - return this.interestCalculationType.getId().intValue(); + return this.interestCalculationType != null ? this.interestCalculationType.getId().intValue() : null; } public Integer getInterestCalculationDaysInYearTypeId() { - return this.interestCalculationDaysInYearType.getId().intValue(); + return this.interestCalculationDaysInYearType != null ? this.interestCalculationDaysInYearType.getId().intValue() : null; } public SavingsAccountTransactionData findLastTransaction(final LocalDate date) { @@ -347,14 +349,14 @@ public final class SavingsAccountData implements Serializable { public LocalDate getActivationLocalDate() { LocalDate activationLocalDate = null; - if (this.timeline.getActivatedOnDate() != null) { + if (this.timeline != null && this.timeline.getActivatedOnDate() != null) { activationLocalDate = this.timeline.getActivatedOnDate(); } return activationLocalDate; } public Integer getLockinPeriodFrequencyTypeId() { - return this.lockinPeriodFrequencyType.getId().intValue(); + return this.lockinPeriodFrequencyType != null ? this.lockinPeriodFrequencyType.getId().intValue() : null; } public Collection findCurrentTransactionIdsWithPivotDateConfig() { @@ -390,15 +392,15 @@ public final class SavingsAccountData implements Serializable { } public List getSavingsAccountTransactionsWithPivotConfig() { - return this.transactions.stream().toList(); + return this.transactions != null ? this.transactions.stream().toList() : null; } public Boolean isAccrualBasedAccountingEnabledOnSavingsProduct() { - return this.savingsProductData.isAccrualBasedAccountingEnabled(); + return this.savingsProductData != null && this.savingsProductData.isAccrualBasedAccountingEnabled(); } public Boolean isCashBasedAccountingEnabledOnSavingsProduct() { - return this.savingsProductData.isCashBasedAccountingEnabled(); + return this.savingsProductData != null && this.savingsProductData.isCashBasedAccountingEnabled(); } public static SavingsAccountData importInstanceGroup(Long groupId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate, @@ -948,7 +950,7 @@ public final class SavingsAccountData implements Serializable { } public Collection charges() { - return (this.charges == null) ? new HashSet() : this.charges; + return this.charges == null ? new HashSet<>() : this.charges; } public void setDatatables(final List datatables) { diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountSummaryData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountSummaryData.java index a4be4cde3..e6f0393fb 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountSummaryData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountSummaryData.java @@ -24,6 +24,7 @@ import java.time.LocalDate; import java.util.HashMap; import java.util.List; import lombok.Getter; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JsonLocalDateArrayFormat; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; @@ -36,6 +37,7 @@ import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; * Immutable data object representing Savings Account summary information. */ @Getter +@JsonLocalDateArrayFormat public class SavingsAccountSummaryData implements Serializable { private final CurrencyData currency; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java index 9a06951b5..8aa9a5868 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java @@ -31,6 +31,7 @@ import java.util.Map; import java.util.Set; import lombok.Getter; import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JsonLocalDateArrayFormat; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; @@ -51,6 +52,7 @@ import org.springframework.util.CollectionUtils; * Immutable data object representing a savings account transaction. */ @Getter +@JsonLocalDateArrayFormat public final class SavingsAccountTransactionData implements Serializable { private Long id; diff --git a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetSavingsAccountByIdCommandStrategy.java b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetSavingsAccountByIdCommandStrategy.java index 7bf56affe..c191feef7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetSavingsAccountByIdCommandStrategy.java +++ b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetSavingsAccountByIdCommandStrategy.java @@ -29,8 +29,13 @@ import org.apache.fineract.batch.command.CommandStrategy; import org.apache.fineract.batch.command.CommandStrategyUtils; import org.apache.fineract.batch.domain.BatchRequest; import org.apache.fineract.batch.domain.BatchResponse; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.api.MutableUriInfo; +import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.portfolio.savings.api.SavingsAccountsApiResource; +import org.apache.fineract.portfolio.savings.api.SavingsApiSetConstants; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; import org.apache.http.HttpStatus; import org.springframework.stereotype.Component; @@ -39,6 +44,8 @@ import org.springframework.stereotype.Component; public class GetSavingsAccountByIdCommandStrategy implements CommandStrategy { private final SavingsAccountsApiResource savingsAccountsApiResource; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final ApiRequestParameterHelper apiRequestParameterHelper; @Override public BatchResponse execute(BatchRequest batchRequest, UriInfo uriInfo) { @@ -57,6 +64,7 @@ public class GetSavingsAccountByIdCommandStrategy implements CommandStrategy { String staffInSelectedOfficeOnly = null; String chargeStatus = null; + String associations = null; if (!queryParameters.isEmpty()) { if (queryParameters.containsKey("staffInSelectedOfficeOnly")) { staffInSelectedOfficeOnly = queryParameters.get("staffInSelectedOfficeOnly"); @@ -64,10 +72,17 @@ public class GetSavingsAccountByIdCommandStrategy implements CommandStrategy { if (queryParameters.containsKey("chargeStatus")) { chargeStatus = queryParameters.get("chargeStatus"); } + if (queryParameters.containsKey("associations")) { + associations = queryParameters.get("associations"); + } } - final String responseBody = savingsAccountsApiResource.retrieveOne(savingsAccountId, - Boolean.parseBoolean(staffInSelectedOfficeOnly), chargeStatus, uriInfo); + SavingsAccountData savingsAccountData = savingsAccountsApiResource.retrieveOne(savingsAccountId, + Boolean.parseBoolean(staffInSelectedOfficeOnly), chargeStatus, associations, uriInfo); + + final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + final String responseBody = toApiJsonSerializer.serialize(settings, savingsAccountData, + SavingsApiSetConstants.SAVINGS_ACCOUNT_RESPONSE_DATA_PARAMETERS); return new BatchResponse().setRequestId(batchRequest.getRequestId()).setStatusCode(HttpStatus.SC_OK).setBody(responseBody) .setHeaders(batchRequest.getHeaders()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyJacksonConverterConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyJacksonConverterConfig.java index ce5cfbdc0..5e52e4dcc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyJacksonConverterConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/JerseyJacksonConverterConfig.java @@ -19,6 +19,7 @@ package org.apache.fineract.infrastructure.core.jersey; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.MapperFeature; @@ -28,6 +29,7 @@ import java.util.List; import org.apache.fineract.infrastructure.core.jersey.converter.JsonConverter; import org.apache.fineract.infrastructure.core.jersey.serializer.JacksonDeserializerAdapter; import org.apache.fineract.infrastructure.core.jersey.serializer.JacksonSerializerAdapter; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JacksonLocalDateArrayModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @@ -43,9 +45,14 @@ public class JerseyJacksonConverterConfig { mergedSerializers.addAll(jsonConverters.stream().map(JacksonSerializerAdapter::new).toList()); List> mergedDeserializers = new ArrayList<>(deserializers); mergedDeserializers.addAll(jsonConverters.stream().map(JacksonDeserializerAdapter::new).toList()); - return new Jackson2ObjectMapperBuilder().serializers(mergedSerializers.toArray(new JsonSerializer[0])) - .serializationInclusion(JsonInclude.Include.NON_NULL).deserializers(mergedDeserializers.toArray(new JsonDeserializer[0])) - .featuresToDisable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .featuresToEnable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS).build(); + ObjectMapper objectMapper = new Jackson2ObjectMapperBuilder() // + .serializers(mergedSerializers.toArray(new JsonSerializer[0])) // + .serializationInclusion(JsonInclude.Include.NON_NULL) // + .deserializers(mergedDeserializers.toArray(new JsonDeserializer[0])) // + .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // + .featuresToEnable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) // + .build(); // + objectMapper.registerModule(new JacksonLocalDateArrayModule()); + return objectMapper; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/converter/LocalDateJsonConverter.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/converter/LocalDateJsonConverter.java index afb264d54..08e9ac026 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/converter/LocalDateJsonConverter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/converter/LocalDateJsonConverter.java @@ -24,9 +24,8 @@ import com.fasterxml.jackson.core.JsonToken; import java.io.IOException; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import org.springframework.stereotype.Component; -@Component +// This is registered in the JacksonLocalDateBeanSerializerModifier public class LocalDateJsonConverter implements JsonConverter { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/JacksonLocalDateBeanSerializerModifier.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/JacksonLocalDateBeanSerializerModifier.java new file mode 100644 index 000000000..d530769a2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/JacksonLocalDateBeanSerializerModifier.java @@ -0,0 +1,59 @@ +/** + * 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.jersey.serializer; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.infrastructure.core.jersey.converter.LocalDateJsonConverter; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JacksonLocalDateArraySerializer; +import org.apache.fineract.infrastructure.core.jersey.serializer.legacy.JsonLocalDateArrayFormat; + +@SuppressFBWarnings({ "SE_BAD_FIELD", "SE_BAD_FIELD_STORE" }) +public class JacksonLocalDateBeanSerializerModifier extends BeanSerializerModifier { + + private final JsonSerializer localDateSerializer = new JacksonSerializerAdapter<>(new LocalDateJsonConverter()); + private final JsonSerializer localDateArraySerializer = new JacksonLocalDateArraySerializer(); + + @Override + @SuppressWarnings("unchecked") + public List changeProperties(SerializationConfig config, BeanDescription beanDesc, + List beanProperties) { + if (beanDesc.getBeanClass().isAnnotationPresent(JsonLocalDateArrayFormat.class)) { + assignLocalDateSerializer(beanProperties, (JsonSerializer) localDateArraySerializer); + } else { + assignLocalDateSerializer(beanProperties, (JsonSerializer) localDateSerializer); + } + + return beanProperties; + } + + private void assignLocalDateSerializer(List beanProperties, JsonSerializer serializer) { + for (BeanPropertyWriter writer : beanProperties) { + if (LocalDate.class.equals(writer.getPropertyType())) { + writer.assignSerializer(serializer); + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArrayModule.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArrayModule.java new file mode 100644 index 000000000..b0f641a5b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArrayModule.java @@ -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.infrastructure.core.jersey.serializer.legacy; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.apache.fineract.infrastructure.core.jersey.serializer.JacksonLocalDateBeanSerializerModifier; + +public class JacksonLocalDateArrayModule extends SimpleModule { + + public JacksonLocalDateArrayModule() { + setSerializerModifier(new JacksonLocalDateBeanSerializerModifier()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArraySerializer.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArraySerializer.java new file mode 100644 index 000000000..1b7b81253 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/jersey/serializer/legacy/JacksonLocalDateArraySerializer.java @@ -0,0 +1,41 @@ +/** + * 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.jersey.serializer.legacy; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.time.LocalDate; + +public class JacksonLocalDateArraySerializer extends JsonSerializer { + + @Override + public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException { + if (value != null) { + gen.writeStartArray(); + gen.writeNumber(value.getYear()); + gen.writeNumber(value.getMonthValue()); + gen.writeNumber(value.getDayOfMonth()); + gen.writeEndArray(); + } else { + gen.writeNull(); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java index 1aa63d0fa..7d0eec08f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountsApiResource.java @@ -43,7 +43,6 @@ import jakarta.ws.rs.core.UriInfo; import java.io.InputStream; import java.util.Arrays; import java.util.Collection; -import java.util.HashSet; import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -187,14 +186,10 @@ public class SavingsAccountsApiResource { @Path("{accountId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Retrieve a savings application/account", description = "Retrieves a savings application/account\n\n" - + "Example Requests :\n" + "\n" + "savingsaccounts/1\n" + "\n" + "\n" + "savingsaccounts/1?associations=all") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.GetSavingsAccountsAccountIdResponse.class))) }) - public String retrieveOne(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, - @DefaultValue("false") @QueryParam("staffInSelectedOfficeOnly") @Parameter(description = "staffInSelectedOfficeOnly") final boolean staffInSelectedOfficeOnly, - @DefaultValue("all") @QueryParam("chargeStatus") @Parameter(description = "chargeStatus") final String chargeStatus, - @Context final UriInfo uriInfo) { + public SavingsAccountData retrieveOne(@PathParam("accountId") final Long accountId, + @DefaultValue("false") @QueryParam("staffInSelectedOfficeOnly") final boolean staffInSelectedOfficeOnly, + @DefaultValue("all") @QueryParam("chargeStatus") final String chargeStatus, + @QueryParam("associations") final String associations, @Context final UriInfo uriInfo) { return retrieveSavingAccount(accountId, null, staffInSelectedOfficeOnly, chargeStatus, uriInfo); } @@ -203,15 +198,10 @@ public class SavingsAccountsApiResource { @Path("/external-id/{externalId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Retrieve a savings application/account by external id", description = "Retrieves a savings application/account by external id\n\n" - + "Example Requests :\n" + "\n" + "savingsaccounts/external-id/ExternalId1\n" + "\n" + "\n" - + "savingsaccounts/external-id/ExternalId1?associations=all") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SavingsAccountsApiResourceSwagger.GetSavingsAccountsAccountIdResponse.class))) }) - public String retrieveOne(@PathParam("externalId") @Parameter(description = "externalId") final String externalId, - @DefaultValue("false") @QueryParam("staffInSelectedOfficeOnly") @Parameter(description = "staffInSelectedOfficeOnly") final boolean staffInSelectedOfficeOnly, - @DefaultValue("all") @QueryParam("chargeStatus") @Parameter(description = "chargeStatus") final String chargeStatus, - @Context final UriInfo uriInfo) { + public SavingsAccountData retrieveOne(@PathParam("externalId") final String externalId, + @DefaultValue("false") @QueryParam("staffInSelectedOfficeOnly") final boolean staffInSelectedOfficeOnly, + @DefaultValue("all") @QueryParam("chargeStatus") final String chargeStatus, + @QueryParam("associations") final String associations, @Context final UriInfo uriInfo) { return retrieveSavingAccount(null, externalId, staffInSelectedOfficeOnly, chargeStatus, uriInfo); } @@ -484,8 +474,8 @@ public class SavingsAccountsApiResource { return toApiJsonSerializer.serialize(importDocumentId); } - private String retrieveSavingAccount(Long accountId, String externalId, boolean staffInSelectedOfficeOnly, String chargeStatus, - UriInfo uriInfo) { + private SavingsAccountData retrieveSavingAccount(Long accountId, String externalId, boolean staffInSelectedOfficeOnly, + String chargeStatus, UriInfo uriInfo) { context.authenticatedUser().validateHasReadPermission(SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME); if (!(is(chargeStatus, "all") || is(chargeStatus, "active") || is(chargeStatus, "inactive"))) { @@ -496,14 +486,7 @@ public class SavingsAccountsApiResource { accountId = getResolvedAccountId(accountId, accountExternalId); final SavingsAccountData savingsAccount = savingsAccountReadPlatformService.retrieveOne(accountId); - final Set mandatoryResponseParameters = new HashSet<>(); - final SavingsAccountData savingsAccountTemplate = populateTemplateAndAssociations(accountId, savingsAccount, - staffInSelectedOfficeOnly, chargeStatus, uriInfo, mandatoryResponseParameters); - - final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters(), - mandatoryResponseParameters); - return toApiJsonSerializer.serialize(settings, savingsAccountTemplate, - SavingsApiSetConstants.SAVINGS_ACCOUNT_RESPONSE_DATA_PARAMETERS); + return populateTemplateAndAssociations(accountId, savingsAccount, staffInSelectedOfficeOnly, chargeStatus, uriInfo); } private String updateSavingAccount(Long accountId, String externalId, String apiRequestBodyAsJson, String commandParam) { @@ -629,8 +612,7 @@ public class SavingsAccountsApiResource { } private SavingsAccountData populateTemplateAndAssociations(final Long accountId, final SavingsAccountData savingsAccount, - final boolean staffInSelectedOfficeOnly, final String chargeStatus, final UriInfo uriInfo, - final Set mandatoryResponseParameters) { + final boolean staffInSelectedOfficeOnly, final String chargeStatus, final UriInfo uriInfo) { Collection transactions = null; Collection charges = null; @@ -643,7 +625,6 @@ public class SavingsAccountsApiResource { } if (associationParameters.contains(SavingsApiConstants.transactions)) { - mandatoryResponseParameters.add(SavingsApiConstants.transactions); final Collection currentTransactions = savingsAccountReadPlatformService .retrieveAllTransactions(accountId, DepositAccountType.SAVINGS_DEPOSIT); if (!CollectionUtils.isEmpty(currentTransactions)) { @@ -652,7 +633,6 @@ public class SavingsAccountsApiResource { } if (associationParameters.contains(SavingsApiConstants.charges)) { - mandatoryResponseParameters.add(SavingsApiConstants.charges); final Collection currentCharges = savingsAccountChargeReadPlatformService .retrieveSavingsAccountCharges(accountId, chargeStatus); if (!CollectionUtils.isEmpty(currentCharges)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/postinterestforsavings/PostInterestForSavingTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/postinterestforsavings/PostInterestForSavingTasklet.java index 0ac4a2c35..f46c77df0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/postinterestforsavings/PostInterestForSavingTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/postinterestforsavings/PostInterestForSavingTasklet.java @@ -56,13 +56,13 @@ public class PostInterestForSavingTasklet implements Tasklet { private final SavingsAccountReadPlatformService savingAccountReadPlatformService; private final ConfigurationDomainService configurationDomainService; - private final Queue> queue = new ArrayDeque<>(); private final ApplicationContext applicationContext; @Qualifier(TaskExecutorConstant.CONFIGURABLE_TASK_EXECUTOR_BEAN_NAME) private final ThreadPoolTaskExecutor taskExecutor; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + final Queue> queue = new ArrayDeque<>(); final int threadPoolSize = Integer.parseInt((String) chunkContext.getStepContext().getJobParameters().get("thread-pool-size")); taskExecutor.setCorePoolSize(threadPoolSize); taskExecutor.setMaxPoolSize(threadPoolSize); @@ -89,7 +89,7 @@ public class PostInterestForSavingTasklet implements Tasklet { log.debug("Starting Interest posting - total records - {}", totalFilteredRecords); List queueElement = queue.element(); maxSavingsIdInList = queueElement.get(queueElement.size() - 1).getId(); - postInterest(queue.remove(), threadPoolSize, backdatedTxnsAllowedTill, pageSize, maxSavingsIdInList); + postInterest(queue.remove(), threadPoolSize, backdatedTxnsAllowedTill, pageSize, maxSavingsIdInList, queue); } while (!CollectionUtils.isEmpty(queue)); } } @@ -97,7 +97,7 @@ public class PostInterestForSavingTasklet implements Tasklet { } private void postInterest(List savingsAccounts, int threadPoolSize, final boolean backdatedTxnsAllowedTill, - final int pageSize, Long maxSavingsIdInList) { + final int pageSize, Long maxSavingsIdInList, Queue> queue) { List> posters = new ArrayList<>(); int fromIndex = 0; int size = savingsAccounts.size(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/savings/api/SelfSavingsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/savings/api/SelfSavingsApiResource.java index e7aaff8c8..0239e385a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/savings/api/SelfSavingsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/savings/api/SelfSavingsApiResource.java @@ -40,11 +40,16 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriInfo; import java.util.HashMap; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.exception.ClientNotFoundException; import org.apache.fineract.portfolio.savings.api.SavingsAccountChargesApiResource; import org.apache.fineract.portfolio.savings.api.SavingsAccountTransactionsApiResource; import org.apache.fineract.portfolio.savings.api.SavingsAccountsApiResource; +import org.apache.fineract.portfolio.savings.api.SavingsApiSetConstants; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; import org.apache.fineract.portfolio.self.client.service.AppuserClientMapperReadService; import org.apache.fineract.portfolio.self.savings.data.SelfSavingsAccountConstants; @@ -66,6 +71,8 @@ public class SelfSavingsApiResource { private final AppuserSavingsMapperReadService appuserSavingsMapperReadService; private final SelfSavingsDataValidator dataValidator; private final AppuserClientMapperReadService appUserClientMapperReadService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final ApiRequestParameterHelper apiRequestParameterHelper; @GET @Path("{accountId}") @@ -77,6 +84,7 @@ public class SelfSavingsApiResource { @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfSavingsApiResourceSwagger.GetSelfSavingsAccountsResponse.class))) }) public String retrieveSavings(@PathParam("accountId") @Parameter(description = "accountId") final Long accountId, @DefaultValue("all") @QueryParam("chargeStatus") @Parameter(description = "chargeStatus") final String chargeStatus, + @QueryParam("associations") @Parameter(description = "associations") final String associations, @Context final UriInfo uriInfo) { this.dataValidator.validateRetrieveSavings(uriInfo); @@ -84,7 +92,10 @@ public class SelfSavingsApiResource { validateAppuserSavingsAccountMapping(accountId); final boolean staffInSelectedOfficeOnly = false; - return this.savingsAccountsApiResource.retrieveOne(accountId, staffInSelectedOfficeOnly, chargeStatus, uriInfo); + SavingsAccountData result = this.savingsAccountsApiResource.retrieveOne(accountId, staffInSelectedOfficeOnly, chargeStatus, + associations, uriInfo); + final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return toApiJsonSerializer.serialize(settings, result, SavingsApiSetConstants.SAVINGS_ACCOUNT_RESPONSE_DATA_PARAMETERS); } @GET diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsApiSetConstants.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsApiSetConstants.java index bce7d2696..efeb6ddb5 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsApiSetConstants.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsApiSetConstants.java @@ -47,17 +47,17 @@ public class SavingsApiSetConstants extends SavingsApiConstants { * These parameters will match the class level parameters of {@link SavingsAccountData}. Where possible, we try to * get response parameters to match those of request parameters. */ - protected static final Set SAVINGS_ACCOUNT_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(idParamName, - accountNoParamName, externalIdParamName, statusParamName, activatedOnDateParamName, staffIdParamName, clientIdParamName, - "clientName", groupIdParamName, "groupName", "savingsProductId", "savingsProductName", "currency", - nominalAnnualInterestRateParamName, interestCompoundingPeriodTypeParamName, interestCalculationTypeParamName, - interestCalculationDaysInYearTypeParamName, minRequiredOpeningBalanceParamName, lockinPeriodFrequencyParamName, - lockinPeriodFrequencyTypeParamName, withdrawalFeeAmountParamName, withdrawalFeeTypeParamName, - withdrawalFeeForTransfersParamName, feeAmountParamName, feeOnMonthDayParamName, "summary", "transactions", "productOptions", - "interestCompoundingPeriodTypeOptions", "interestPostingPeriodTypeOptions", "interestCalculationTypeOptions", - "interestCalculationDaysInYearTypeOptions", "lockinPeriodFrequencyTypeOptions", "withdrawalFeeTypeOptions", "withdrawalFee", - "annualFee", onHoldFundsParamName, nominalAnnualInterestRateOverdraftParamName, minOverdraftForInterestCalculationParamName, - datatables, savingsAmountOnHold, accountMappingForPaymentParamName, interestPostedTillDate)); + public static final Set SAVINGS_ACCOUNT_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(idParamName, accountNoParamName, + externalIdParamName, statusParamName, activatedOnDateParamName, staffIdParamName, clientIdParamName, "clientName", + groupIdParamName, "groupName", "savingsProductId", "savingsProductName", "currency", nominalAnnualInterestRateParamName, + interestCompoundingPeriodTypeParamName, interestCalculationTypeParamName, interestCalculationDaysInYearTypeParamName, + minRequiredOpeningBalanceParamName, lockinPeriodFrequencyParamName, lockinPeriodFrequencyTypeParamName, + withdrawalFeeAmountParamName, withdrawalFeeTypeParamName, withdrawalFeeForTransfersParamName, feeAmountParamName, + feeOnMonthDayParamName, "summary", "transactions", "productOptions", "interestCompoundingPeriodTypeOptions", + "interestPostingPeriodTypeOptions", "interestCalculationTypeOptions", "interestCalculationDaysInYearTypeOptions", + "lockinPeriodFrequencyTypeOptions", "withdrawalFeeTypeOptions", "withdrawalFee", "annualFee", onHoldFundsParamName, + nominalAnnualInterestRateOverdraftParamName, minOverdraftForInterestCalculationParamName, datatables, savingsAmountOnHold, + accountMappingForPaymentParamName, interestPostedTillDate)); protected static final Set SAVINGS_TRANSACTION_RESPONSE_DATA_PARAMETERS = new HashSet<>( Arrays.asList(idParamName, "accountId", accountNoParamName, "currency", "amount", dateParamName, paymentDetailDataParamName, diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java index a7c57b718..295d3f55a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java @@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -61,7 +62,7 @@ public class SavingsSchedularInterestPoster { private Collection savingAccounts; private boolean backdatedTxnsAllowedTill; - @Transactional(rollbackFor = Exception.class) + @Transactional(isolation = Isolation.READ_UNCOMMITTED, rollbackFor = Exception.class) public void postInterest() throws JobExecutionException { if (!savingAccounts.isEmpty()) { List errors = new ArrayList<>(); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsExternalIdTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsExternalIdTest.java index ab7f3d467..2f2b13621 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsExternalIdTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccountsExternalIdTest.java @@ -18,18 +18,19 @@ */ package org.apache.fineract.integrationtests; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.UUID; import org.apache.fineract.client.models.DeleteSavingsAccountsAccountIdResponse; -import org.apache.fineract.client.models.GetSavingsAccountsAccountIdResponse; import org.apache.fineract.client.models.PostSavingsAccountsAccountIdRequest; 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.util.Calls; import org.apache.fineract.integrationtests.client.IntegrationTest; import org.junit.jupiter.api.Order; @@ -100,13 +101,12 @@ public class SavingsAccountsExternalIdTest extends IntegrationTest { request.dateFormat(dateFormat); request.setLocale(locale); request.setActivatedOnDate(formattedDate); - Response response = okR( - fineractClient().savingsAccounts.retrieveOne26(EXTERNAL_ID, false, "all")); + Response response = okR(fineractClient().savingsAccounts.retrieveOne26(EXTERNAL_ID, false, null, "all")); assertThat(response.isSuccessful()).isTrue(); assertThat(response.body()).isNotNull(); assertThat(response.body().getStatus().getCode()).isEqualTo("savingsAccountStatusType.approved"); - assertThat(response.body().getNominalAnnualInterestRate()).isEqualTo(5.999); + assertThat(response.body().getNominalAnnualInterestRate()).isEqualByComparingTo(BigDecimal.valueOf(5.999)); } @Test @@ -129,8 +129,7 @@ public class SavingsAccountsExternalIdTest extends IntegrationTest { request.dateFormat(dateFormat); request.setLocale(locale); request.setActivatedOnDate(formattedDate); - Response response = okR( - fineractClient().savingsAccounts.retrieveOne26(EXTERNAL_ID, false, "all")); + Response response = okR(fineractClient().savingsAccounts.retrieveOne26(EXTERNAL_ID, false, null, "all")); assertThat(response.isSuccessful()).isTrue(); assertThat(response.body()).isNotNull(); @@ -159,8 +158,8 @@ public class SavingsAccountsExternalIdTest extends IntegrationTest { request.dateFormat(dateFormat); request.setLocale(locale); request.setActivatedOnDate(formattedDate); - Response response = Calls - .executeU(fineractClient().savingsAccounts.retrieveOne26(EXTERNAL_ID, false, "all")); + Response response = Calls + .executeU(fineractClient().savingsAccounts.retrieveOne26(EXTERNAL_ID, false, null, "all")); assertThat(response.raw().code()).isEqualTo(404); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/SavingsInterestPostingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/SavingsInterestPostingTest.java new file mode 100644 index 000000000..82c074a97 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/SavingsInterestPostingTest.java @@ -0,0 +1,108 @@ +/** + * 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.savings; + +import java.math.BigDecimal; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; +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.client.models.SavingsAccountTransactionData; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.savings.base.BaseSavingsIntegrationTest; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +@Order(2) +public class SavingsInterestPostingTest extends BaseSavingsIntegrationTest { + + @Test + public void testSavingsInterestPosting_Works_ForMultipleAccounts() { + List accounts = new ArrayList<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + // Create product + PostSavingsProductsRequest productsRequest = dailyInterestPostingProduct(); + PostSavingsProductsResponse product = createProduct(productsRequest); + Integer productId = product.getResourceId(); + + // Create accounts + IntStream.range(0, 200)// + .parallel().mapToObj(i -> applySavingsRequest(clientId, productId.longValue(), "01 January 2023"))// + .map(this::applySavingsAccount) // + .mapToLong(savingsAccount -> savingsAccount.getResourceId().longValue()) // + .forEach(savingsId -> { + approveSavingsAccount(savingsId, "01 January 2023"); + activateSavingsAccount(savingsId, "01 January 2023"); + accounts.add(new AccountWithDepositAmount(savingsId)); + }); + }); + + runFromToInclusive("02 January 2023", "10 January 2023", (date) -> { + SecureRandom rnd = new SecureRandom(); + accounts.parallelStream().forEach(account -> { + long lAmount = rnd.nextLong(10_000_000L); + BigDecimal amount = BigDecimal.valueOf(lAmount); + deposit(account.savingsId, date, amount); + account.addDeposit(amount); + }); + }); + + runAt("11 January 2023", () -> { + String jobName = "Post Interest For Savings"; + schedulerJobHelper.executeAndAwaitJob(jobName); + + accounts.parallelStream().forEach(account -> { + SavingsAccountData accountData = getSavingsAccount(account.savingsId); + BigDecimal accountBalance = accountData.getSummary().getAccountBalance(); + BigDecimal accountBalanceByTransactions = accountData.getTransactions().stream() + .map(SavingsAccountTransactionData::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + assertThat(accountBalance) + .withFailMessage("Account balance (%s) and calculated balance (%s) from transactions do not match", accountBalance, + accountBalanceByTransactions) + .isEqualByComparingTo(accountBalanceByTransactions); + BigDecimal summedDepositAmount = accountData.getTransactions().stream().filter(tx -> tx.getTransactionType().getDeposit()) + .map(SavingsAccountTransactionData::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + assertThat(summedDepositAmount).withFailMessage( + "Calculated balance (%s) from deposit transactions and submitted deposit balance (%s) do not match", accountBalance, + summedDepositAmount).isEqualByComparingTo(account.depositedAmount); + }); + }); + } + + static class AccountWithDepositAmount { + + public Long savingsId; + public BigDecimal depositedAmount = BigDecimal.ZERO; + + AccountWithDepositAmount(Long savingsId) { + this.savingsId = savingsId; + } + + public void addDeposit(BigDecimal amount) { + depositedAmount = depositedAmount.add(amount); + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/base/BaseSavingsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/base/BaseSavingsIntegrationTest.java new file mode 100644 index 000000000..95e5f92f1 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/savings/base/BaseSavingsIntegrationTest.java @@ -0,0 +1,249 @@ +/** + * 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.savings.base; + +import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; + +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.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import lombok.AllArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.BusinessDateRequest; +import org.apache.fineract.client.models.PostSavingsAccountTransactionsRequest; +import org.apache.fineract.client.models.PostSavingsAccountTransactionsResponse; +import org.apache.fineract.client.models.PostSavingsAccountsAccountIdRequest; +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.PostSavingsProductsRequest; +import org.apache.fineract.client.models.PostSavingsProductsResponse; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.client.models.SavingsAccountData; +import org.apache.fineract.client.models.SavingsAccountTransactionData; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.integrationtests.client.IntegrationTest; +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.integrationtests.common.SchedulerJobHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; +import org.apache.fineract.integrationtests.common.system.CodeHelper; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; + +@Slf4j +public class BaseSavingsIntegrationTest extends IntegrationTest { + + protected static final String DATETIME_PATTERN = "dd MMMM yyyy"; + + static { + Utils.initializeRESTAssured(); + } + + protected final ResponseSpecification responseSpec = createResponseSpecification(Matchers.is(200)); + private final String fullAdminAuthKey = getFullAdminAuthKey(); + protected final RequestSpecification requestSpec = createRequestSpecification(fullAdminAuthKey); + + protected BusinessDateHelper businessDateHelper = new BusinessDateHelper(); + protected DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN); + protected GlobalConfigurationHelper globalConfigurationHelper = new GlobalConfigurationHelper(); + protected final CodeHelper codeHelper = new CodeHelper(); + protected JournalEntryHelper journalEntryHelper = new JournalEntryHelper(requestSpec, responseSpec); + protected ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec); + protected SchedulerJobHelper schedulerJobHelper = new SchedulerJobHelper(requestSpec); + + protected void runFromToInclusive(String fromDate, String toDate, Consumer runnable) { + DateTimeFormatter format = DateTimeFormatter.ofPattern(DATETIME_PATTERN); + LocalDate startDate = LocalDate.parse(fromDate, format); + LocalDate endDate = LocalDate.parse(toDate, format); + + LocalDate currentDate = startDate; + while (currentDate.isBefore(endDate) || currentDate.isEqual(endDate)) { + runAt(format.format(currentDate), runnable); + currentDate = currentDate.plusDays(1); + } + } + + protected void runAt(String date, Runnable runnable) { + runAt(date, (d) -> runnable.run()); + } + + protected void runAt(String date, Consumer runnable) { + try { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(true)); + businessDateHelper.updateBusinessDate( + new BusinessDateRequest().type(BUSINESS_DATE.getName()).date(date).dateFormat(DATETIME_PATTERN).locale("en")); + runnable.accept(date); + } finally { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(false)); + } + } + + protected PostSavingsProductsRequest dailyInterestPostingProduct() { + return new PostSavingsProductsRequest().locale("en").name(Utils.uniqueRandomStringGenerator("DAILY_INTEREST", 6))// + .shortName(Utils.uniqueRandomStringGenerator("", 4))// + .description("Daily interest posting product") // + .nominalAnnualInterestRate(10.0) // + .digitsAfterDecimal(0) // + .currencyCode("EUR") // + .accountingRule(1) // none + .interestCalculationDaysInYearType(DaysInYearType.DAYS_365).interestCompoundingPeriodType(InterestPeriodType.DAILY) + .interestCalculationType(InterestCalculationType.AVERAGE_DAILY_BALANCE) // + .interestPostingPeriodType(InterestPeriodType.DAILY);// + } + + protected PostSavingsProductsResponse createProduct(PostSavingsProductsRequest productsRequest) { + return ok(fineractClient().savingsProducts.create13(productsRequest)); + } + + protected PostSavingsAccountsRequest applySavingsRequest(Long clientId, Long productId, String submittedDate) { + return new PostSavingsAccountsRequest().clientId(clientId.intValue()).productId(productId.intValue()).dateFormat(DATETIME_PATTERN) + .locale("en").submittedOnDate(submittedDate); + } + + protected PostSavingsAccountsResponse applySavingsAccount(PostSavingsAccountsRequest request) { + return ok(fineractClient().savingsAccounts.submitApplication2(request)); + } + + protected PostSavingsAccountsAccountIdResponse approveSavingsAccount(Long savingsId, String date) { + PostSavingsAccountsAccountIdRequest request = new PostSavingsAccountsAccountIdRequest().dateFormat(DATETIME_PATTERN).locale("en") + .approvedOnDate(date); + return ok(fineractClient().savingsAccounts.handleCommands6(savingsId, request, "approve")); + } + + protected PostSavingsAccountsAccountIdResponse activateSavingsAccount(Long savingsId, String date) { + PostSavingsAccountsAccountIdRequest request = new PostSavingsAccountsAccountIdRequest().dateFormat(DATETIME_PATTERN).locale("en") + .activatedOnDate(date); + return ok(fineractClient().savingsAccounts.handleCommands6(savingsId, request, "activate")); + } + + protected PostSavingsAccountTransactionsResponse deposit(Long savingsId, String date, BigDecimal amount) { + PostSavingsAccountTransactionsRequest request = new PostSavingsAccountTransactionsRequest() // + .dateFormat(DATETIME_PATTERN) // + .locale("en") // + .paymentTypeId(1).transactionAmount(amount) // + .transactionDate(date); // + return ok(fineractClient().savingsTransactions.transaction2(savingsId, request, "deposit")); + } + + protected SavingsAccountData getSavingsAccount(Long savingsId) { + return ok(fineractClient().savingsAccounts.retrieveOne25(savingsId, false, null, "transactions")); + } + + protected List getTransactions(Long savingsId) { + return ok(fineractClient().savingsAccounts.retrieveOne25(savingsId, false, null, "transactions")).getTransactions(); + } + + protected void verifyNoTransactions(Long savingsId) { + verifyTransactions(savingsId, (Transaction[]) null); + } + + protected void verifyTransactions(Long savingsId, Transaction... transactions) { + SavingsAccountData savingsDetails = ok(fineractClient().savingsAccounts.retrieveOne25(savingsId, false, null, "transactions")); + if (transactions == null || transactions.length == 0) { + Assertions.assertTrue(savingsDetails.getTransactions().isEmpty(), "No transaction is expected on savings account " + savingsId); + } else { + Assertions.assertEquals(transactions.length, savingsDetails.getTransactions().size()); + Arrays.stream(transactions).forEach(tr -> { + Optional optTx = savingsDetails.getTransactions().stream() + .filter(item -> Objects.requireNonNull(item.getAmount()).compareTo(BigDecimal.valueOf(tr.amount)) == 0 // + && Objects.equals(item.getTransactionType().getValue(), tr.type) // + && Objects.equals(item.getDate(), LocalDate.parse(tr.date, dateTimeFormatter))) + .findFirst(); + Assertions.assertTrue(optTx.isPresent(), "Required transaction not found: " + tr + " on savings account " + savingsId); + + SavingsAccountTransactionData tx = optTx.get(); + + if (tr.reversed != null) { + Assertions.assertEquals(tr.reversed, tx.getReversed(), + "Transaction is not reversed: " + tr + " on savings account " + savingsId); + } + }); + } + } + + private RequestSpecification createRequestSpecification(String authKey) { + RequestSpecification requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + requestSpec.header("Authorization", "Basic " + authKey); + requestSpec.header("Fineract-Platform-TenantId", "default"); + return requestSpec; + } + + protected ResponseSpecification createResponseSpecification(Matcher statusCodeMatcher) { + return new ResponseSpecBuilder().expectStatusCode(statusCodeMatcher).build(); + } + + private String getFullAdminAuthKey() { + return Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey(); + } + + @ToString + @AllArgsConstructor + public static class Transaction { + + Double amount; + String type; + String date; + Boolean reversed; + } + + public static class InterestPeriodType { + + public static final int DAILY = 1; + public static final int MONTHLY = 4; + public static final int QUATERLY = 5; + public static final int BIANNUAL = 6; + } + + public static class InterestCalculationType { + + public static final int DAILY_BALANCE = 1; + public static final int AVERAGE_DAILY_BALANCE = 2; + } + + public static class InterestRecalculationCompoundingMethod { + + public static final Integer NONE = 0; + } + + public static class DaysInYearType { + + public static final Integer INVALID = 0; + public static final Integer ACTUAL = 1; + public static final Integer DAYS_360 = 360; + public static final Integer DAYS_364 = 364; + public static final Integer DAYS_365 = 365; + } +}