From f681ee9ca58577f8a4634fb067588951554be491 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Tue, 7 Nov 2023 18:25:06 +0100 Subject: [PATCH] FINERACT-1971: Fix for infinite recursive loop in CalendarUtils when tenant and system TZ is different --- .../monetary/domain/ApplicationCurrency.java | 2 +- .../monetary/domain/MoneyHelper.java | 4 +- .../domain/RepaymentRescheduleType.java | 11 +- .../workingdays/domain/WorkingDays.java | 2 +- .../workingdays/service/WorkingDaysUtil.java | 2 +- .../calendar/service/CalendarUtils.java | 34 +++-- .../junit/context/WithTenantContext.java | 35 +++++ .../context/WithTenantContextExtension.java | 59 ++++++++ .../junit/system/WithSystemProperties.java | 31 ++++ .../junit/system/WithSystemProperty.java | 35 +++++ .../system/WithSystemPropertyExtension.java | 82 +++++++++++ .../junit/timezone/WithSystemTimeZone.java | 31 ++++ .../timezone/WithSystemTimeZoneExtension.java | 61 ++++++++ .../calendar/service/CalendarUtilsTest.java | 65 ++++++++- .../DefaultScheduledDateGeneratorTest.java | 136 ++++++++++++++++++ .../fineract/util/TimeZoneConstants.java | 28 ++++ 16 files changed, 593 insertions(+), 25 deletions(-) create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContext.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContextExtension.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperties.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperty.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemPropertyExtension.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZone.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZoneExtension.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/util/TimeZoneConstants.java diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrency.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrency.java index 1de2af4df..1c214d802 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrency.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/ApplicationCurrency.java @@ -61,7 +61,7 @@ public class ApplicationCurrency extends AbstractPersistableCustom { currency.displaySymbol); } - private ApplicationCurrency(final String code, final String name, final int decimalPlaces, final Integer inMultiplesOf, + public ApplicationCurrency(final String code, final String name, final int decimalPlaces, final Integer inMultiplesOf, final String nameCode, final String displaySymbol) { this.code = code; this.name = name; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java index d2e8337a6..baaf576da 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/domain/MoneyHelper.java @@ -38,11 +38,11 @@ public class MoneyHelper { @Autowired private ConfigurationDomainService configurationDomainService; - @PostConstruct // This is a hack, but fixing this is not trivial, because some @Entity // domain classes use this helper + @PostConstruct @SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") - public void someFunction() { + public void initialize() { staticConfigurationDomainService = configurationDomainService; } diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/RepaymentRescheduleType.java b/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/RepaymentRescheduleType.java index d56ab13ca..871e40dd5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/RepaymentRescheduleType.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/RepaymentRescheduleType.java @@ -25,11 +25,12 @@ import lombok.Getter; @Getter public enum RepaymentRescheduleType { - INVALID(0, "RepaymentRescheduleType.invalid"), SAME_DAY(1, "RepaymentRescheduleType.same.day"), MOVE_TO_NEXT_WORKING_DAY(2, - "RepaymentRescheduleType.move.to.next.working.day"), MOVE_TO_NEXT_REPAYMENT_MEETING_DAY(3, - "RepaymentRescheduleType.move.to.next.repayment.meeting.day"), MOVE_TO_PREVIOUS_WORKING_DAY(4, - "RepaymentRescheduleType.move.to.previous.working.day"), MOVE_TO_NEXT_MEETING_DAY(5, - "RepaymentRescheduleType.move.to.next.meeting.day"); + INVALID(0, "RepaymentRescheduleType.invalid"), // + SAME_DAY(1, "RepaymentRescheduleType.same.day"), // + MOVE_TO_NEXT_WORKING_DAY(2, "RepaymentRescheduleType.move.to.next.working.day"), // + MOVE_TO_NEXT_REPAYMENT_MEETING_DAY(3, "RepaymentRescheduleType.move.to.next.repayment.meeting.day"), // + MOVE_TO_PREVIOUS_WORKING_DAY(4, "RepaymentRescheduleType.move.to.previous.working.day"), // + MOVE_TO_NEXT_MEETING_DAY(5, "RepaymentRescheduleType.move.to.next.meeting.day"); // private final Integer value; private final String code; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/WorkingDays.java b/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/WorkingDays.java index 16a637a6b..a8a3e78a6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/WorkingDays.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/domain/WorkingDays.java @@ -51,7 +51,7 @@ public class WorkingDays extends AbstractPersistableCustom { } - protected WorkingDays(final String recurrence, final Integer repaymentReschedulingType, final Boolean extendTermForDailyRepayments, + public WorkingDays(final String recurrence, final Integer repaymentReschedulingType, final Boolean extendTermForDailyRepayments, final Boolean extendTermForRepaymentsOnHolidays) { this.recurrence = recurrence; this.repaymentReschedulingType = repaymentReschedulingType; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/service/WorkingDaysUtil.java b/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/service/WorkingDaysUtil.java index cb0d6e17e..fec8fc320 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/service/WorkingDaysUtil.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/workingdays/service/WorkingDaysUtil.java @@ -33,7 +33,7 @@ public final class WorkingDaysUtil { public static LocalDate getOffSetDateIfNonWorkingDay(final LocalDate date, final LocalDate nextMeetingDate, final WorkingDays workingDays) { - // If date is not a non working day then return date. + // If date is a working day then return date. if (isWorkingDay(workingDays, date)) { return date; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/calendar/service/CalendarUtils.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/calendar/service/CalendarUtils.java index b06c1afaa..aa9fed213 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/calendar/service/CalendarUtils.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/calendar/service/CalendarUtils.java @@ -32,6 +32,7 @@ import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.StringTokenizer; +import lombok.extern.slf4j.Slf4j; import net.fortuna.ical4j.model.Date; import net.fortuna.ical4j.model.DateList; import net.fortuna.ical4j.model.DateTime; @@ -53,19 +54,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarFrequencyType; import org.apache.fineract.portfolio.calendar.domain.CalendarWeekDaysType; import org.apache.fineract.portfolio.common.domain.NthDayType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +@Slf4j public final class CalendarUtils { + public static final String FLOATING_TIMEZONE_PROPERTY_KEY = "net.fortuna.ical4j.timezone.date.floating"; + private CalendarUtils() { } - private static final Logger LOG = LoggerFactory.getLogger(CalendarUtils.class); - static { - System.setProperty("net.fortuna.ical4j.timezone.date.floating", "true"); + System.setProperty(FLOATING_TIMEZONE_PROPERTY_KEY, "true"); } public static LocalDateTime getNextRecurringDate(final String recurringRule, final LocalDateTime seedDate, @@ -119,7 +119,7 @@ public final class CalendarUtils { try { formattedDate = new Date(seedDateStr, DateUtils.DEFAULT_DATETIME_FORMAT); } catch (final ParseException e) { - LOG.error("Invalid date: {}", seedDateStr, e); + log.error("Invalid date: {}", seedDateStr, e); } return formattedDate; } @@ -170,15 +170,15 @@ public final class CalendarUtils { numberOfDays); } - private static Collection convertToLocalDateList(final DateList dates, final LocalDate seedDate, + static Collection convertToLocalDateList(final DateList dates, final LocalDate seedDate, final PeriodFrequencyType frequencyType, boolean isSkippMeetingOnFirstDay, final Integer numberOfDays) { final Collection recurringDates = new ArrayList<>(); - for (@SuppressWarnings("rawtypes") - final Iterator iterator = dates.iterator(); iterator.hasNext();) { - final Date date = (Date) iterator.next(); - recurringDates.add((LocalDate) adjustDate(LocalDate.ofInstant(date.toInstant(), DateUtils.getDateTimeZoneOfTenant()), seedDate, - frequencyType)); + for (final Date date : dates) { + LocalDateTime dateTimeInProperTz = getLocalDateTimeFromICal4JDate(date); + ZoneId tenantZoneId = DateUtils.getDateTimeZoneOfTenant(); + + recurringDates.add((LocalDate) adjustDate(dateTimeInProperTz.atZone(tenantZoneId).toLocalDate(), seedDate, frequencyType)); } if (isSkippMeetingOnFirstDay) { @@ -188,6 +188,12 @@ public final class CalendarUtils { return recurringDates; } + private static LocalDateTime getLocalDateTimeFromICal4JDate(Date date) { + java.util.Calendar cal = java.util.Calendar.getInstance(); + cal.setTime(date); + return LocalDateTime.ofInstant(cal.toInstant(), cal.getTimeZone().toZoneId()); + } + private static Collection skipMeetingOnFirstdayOfMonth(final Collection recurringDates, final Integer numberOfDays) { final Collection adjustedRecurringDates = new ArrayList<>(); @@ -220,10 +226,10 @@ public final class CalendarUtils { return recur; } catch (final ParseException e) { // TODO Auto-generated catch block - LOG.error("Problem occurred in getICalRecur function", e); + log.error("Problem occurred in getICalRecur function", e); } catch (final ValidationException e) { // TODO Auto-generated catch block - LOG.error("Problem occurred in getICalRecur function", e); + log.error("Problem occurred in getICalRecur function", e); } return null; diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContext.java b/fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContext.java new file mode 100644 index 000000000..bae0813a0 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContext.java @@ -0,0 +1,35 @@ +/** + * 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.junit.context; + +import static org.apache.fineract.util.TimeZoneConstants.ASIA_KOLKATA_ID; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD }) +public @interface WithTenantContext { + + String tenantName() default "default"; + + String tenantTimeZoneId() default ASIA_KOLKATA_ID; +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContextExtension.java b/fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContextExtension.java new file mode 100644 index 000000000..042d2545b --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/context/WithTenantContextExtension.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.junit.context; + +import java.lang.reflect.Method; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class WithTenantContextExtension implements BeforeEachCallback, AfterEachCallback { + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(WithTenantContextExtension.class); + + private static final String ORIGINAL_TENANT_KEY = "originalTenant"; + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + Method method = context.getTestMethod().orElse(null); + if (hasAnnotation(method)) { + context.getStore(NAMESPACE).put(ORIGINAL_TENANT_KEY, ThreadLocalContextUtil.getTenant()); + + WithTenantContext annotation = method.getAnnotation(WithTenantContext.class); + ThreadLocalContextUtil + .setTenant(new FineractPlatformTenant(1L, annotation.tenantName(), "Default", annotation.tenantTimeZoneId(), null)); + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + Method method = context.getTestMethod().orElse(null); + if (hasAnnotation(method)) { + FineractPlatformTenant originalTenant = (FineractPlatformTenant) context.getStore(NAMESPACE).get(ORIGINAL_TENANT_KEY); + + ThreadLocalContextUtil.setTenant(originalTenant); + } + } + + private boolean hasAnnotation(Method method) { + return method != null && method.getAnnotation(WithTenantContext.class) != null; + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperties.java b/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperties.java new file mode 100644 index 000000000..fd94c3c54 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperties.java @@ -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.junit.system; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD }) +public @interface WithSystemProperties { + + WithSystemProperty[] value(); +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperty.java b/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperty.java new file mode 100644 index 000000000..28a012df5 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemProperty.java @@ -0,0 +1,35 @@ +/** + * 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.junit.system; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD }) +@Repeatable(WithSystemProperties.class) +public @interface WithSystemProperty { + + String key(); + + String value(); +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemPropertyExtension.java b/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemPropertyExtension.java new file mode 100644 index 000000000..22b4dc45e --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/system/WithSystemPropertyExtension.java @@ -0,0 +1,82 @@ +/** + * 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.junit.system; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class WithSystemPropertyExtension implements BeforeEachCallback, AfterEachCallback { + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(WithSystemPropertyExtension.class); + + private static final String ORIGINAL_SYSTEM_PROPERTY_KEY_PATTERN = "originalSystemProperty_%s"; + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + Method method = context.getTestMethod().orElse(null); + if (hasAnnotation(method)) { + WithSystemProperty[] annotations = method.getAnnotationsByType(WithSystemProperty.class); + for (WithSystemProperty annotation : annotations) { + String propKey = annotation.key(); + String propValue = annotation.value(); + + saveOriginalSystemPropertyValue(context, propKey); + overrideSystemPropertyValue(propKey, propValue); + } + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + Method method = context.getTestMethod().orElse(null); + if (hasAnnotation(method)) { + WithSystemProperty[] annotations = method.getAnnotationsByType(WithSystemProperty.class); + for (WithSystemProperty annotation : annotations) { + String propKey = annotation.key(); + String propValue = loadOriginalSystemPropertyValue(context, propKey); + + overrideSystemPropertyValue(propKey, propValue); + } + } + } + + private void overrideSystemPropertyValue(String propKey, String propValue) { + if (propValue == null) { + System.clearProperty(propKey); + } else { + System.setProperty(propKey, propValue); + } + } + + private void saveOriginalSystemPropertyValue(ExtensionContext context, String propKey) { + String originalKey = ORIGINAL_SYSTEM_PROPERTY_KEY_PATTERN.formatted(propKey); + context.getStore(NAMESPACE).put(originalKey, System.getProperty(propKey)); + } + + private String loadOriginalSystemPropertyValue(ExtensionContext context, String propKey) { + String originalKey = ORIGINAL_SYSTEM_PROPERTY_KEY_PATTERN.formatted(propKey); + return (String) context.getStore(NAMESPACE).get(originalKey); + } + + private boolean hasAnnotation(Method method) { + return method != null && method.getAnnotation(WithSystemProperty.class) != null; + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZone.java b/fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZone.java new file mode 100644 index 000000000..d792d1d9e --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZone.java @@ -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.junit.timezone; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD }) +public @interface WithSystemTimeZone { + + String value(); +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZoneExtension.java b/fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZoneExtension.java new file mode 100644 index 000000000..5332fdac1 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/junit/timezone/WithSystemTimeZoneExtension.java @@ -0,0 +1,61 @@ +/** + * 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.junit.timezone; + +import java.lang.reflect.Method; +import java.util.TimeZone; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class WithSystemTimeZoneExtension implements BeforeEachCallback, AfterEachCallback { + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(WithSystemTimeZoneExtension.class); + + private static final String ORIGINAL_TIMEZONE_KEY = "originalTimeZone"; + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + Method method = context.getTestMethod().orElse(null); + if (hasAnnotation(method)) { + context.getStore(NAMESPACE).put(ORIGINAL_TIMEZONE_KEY, TimeZone.getDefault()); + + String timeZoneId = getTimeZoneId(method); + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)); + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + Method method = context.getTestMethod().orElse(null); + if (hasAnnotation(method)) { + TimeZone originalTimeZone = (TimeZone) context.getStore(NAMESPACE).get(ORIGINAL_TIMEZONE_KEY); + + TimeZone.setDefault(originalTimeZone); + } + } + + private String getTimeZoneId(Method method) { + return method.getAnnotation(WithSystemTimeZone.class).value(); + } + + private boolean hasAnnotation(Method method) { + return method != null && method.getAnnotation(WithSystemTimeZone.class) != null; + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/calendar/service/CalendarUtilsTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/calendar/service/CalendarUtilsTest.java index f859dec80..17e6c18a6 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/calendar/service/CalendarUtilsTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/calendar/service/CalendarUtilsTest.java @@ -18,15 +18,36 @@ */ package org.apache.fineract.portfolio.calendar.service; +import static org.apache.fineract.portfolio.calendar.service.CalendarUtils.FLOATING_TIMEZONE_PROPERTY_KEY; +import static org.apache.fineract.portfolio.common.domain.PeriodFrequencyType.WEEKS; +import static org.apache.fineract.util.TimeZoneConstants.ASIA_MANILA_ID; +import static org.apache.fineract.util.TimeZoneConstants.EUROPE_BERLIN_ID; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import java.text.ParseException; import java.time.LocalDate; import java.time.Month; import java.time.temporal.ChronoField; import java.time.temporal.Temporal; +import java.util.Collection; +import java.util.List; +import net.fortuna.ical4j.model.Date; +import net.fortuna.ical4j.model.DateList; +import net.fortuna.ical4j.model.TimeZone; +import net.fortuna.ical4j.model.TimeZoneRegistryFactory; +import net.fortuna.ical4j.model.parameter.Value; +import org.apache.fineract.junit.context.WithTenantContext; +import org.apache.fineract.junit.context.WithTenantContextExtension; +import org.apache.fineract.junit.system.WithSystemProperty; +import org.apache.fineract.junit.system.WithSystemPropertyExtension; +import org.apache.fineract.junit.timezone.WithSystemTimeZone; +import org.apache.fineract.junit.timezone.WithSystemTimeZoneExtension; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +@ExtendWith({ WithSystemTimeZoneExtension.class, WithTenantContextExtension.class, WithSystemPropertyExtension.class }) public class CalendarUtilsTest { @Test @@ -120,7 +141,7 @@ public class CalendarUtilsTest { Temporal seedDate = LocalDate.of(2023, Month.JANUARY, 27); // when - Temporal adjustedDateWeekly = CalendarUtils.adjustDate(date, seedDate, PeriodFrequencyType.WEEKS); + Temporal adjustedDateWeekly = CalendarUtils.adjustDate(date, seedDate, WEEKS); // then assertEquals(30, adjustedDateWeekly.get(ChronoField.DAY_OF_MONTH)); @@ -136,4 +157,46 @@ public class CalendarUtilsTest { } + @Test + @WithSystemTimeZone(ASIA_MANILA_ID) + @WithTenantContext(tenantTimeZoneId = EUROPE_BERLIN_ID) + @WithSystemProperty(key = FLOATING_TIMEZONE_PROPERTY_KEY, value = "true") + public void testConvertToLocalDateListWorksForDifferentTimezones() throws ParseException { + // given + TimeZone timeZone = TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Asia/Manila"); + DateList dates = new DateList(Value.DATE, timeZone); + dates.add(new Date("20231104", "yyyyMMdd")); + + LocalDate seedDate = LocalDate.of(2023, 11, 4); + + // when + Collection result = CalendarUtils.convertToLocalDateList(dates, seedDate, WEEKS, false, 0); + // then + Collection expected = List.of(LocalDate.of(2023, 11, 4)); + + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expected); + } + + @Test + @WithSystemTimeZone(EUROPE_BERLIN_ID) + @WithTenantContext(tenantTimeZoneId = EUROPE_BERLIN_ID) + @WithSystemProperty(key = FLOATING_TIMEZONE_PROPERTY_KEY, value = "true") + public void testConvertToLocalDateListWorksForSameTimezones() throws ParseException { + // given + TimeZone timeZone = TimeZoneRegistryFactory.getInstance().createRegistry().getTimeZone("Europe/Berlin"); + DateList dates = new DateList(Value.DATE, timeZone); + dates.add(new Date("20231104", "yyyyMMdd")); + + LocalDate seedDate = LocalDate.of(2023, 11, 4); + + // when + Collection result = CalendarUtils.convertToLocalDateList(dates, seedDate, WEEKS, false, 0); + // then + Collection expected = List.of(LocalDate.of(2023, 11, 4)); + + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expected); + } + } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java new file mode 100644 index 000000000..30adc6181 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java @@ -0,0 +1,136 @@ +/** + * 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.loanaccount.loanschedule.domain; + +import static java.math.BigDecimal.ZERO; +import static java.util.Collections.EMPTY_LIST; +import static org.apache.fineract.organisation.monetary.domain.MonetaryCurrency.fromApplicationCurrency; +import static org.apache.fineract.organisation.workingdays.domain.RepaymentRescheduleType.MOVE_TO_NEXT_WORKING_DAY; +import static org.apache.fineract.portfolio.calendar.service.CalendarUtils.FLOATING_TIMEZONE_PROPERTY_KEY; +import static org.apache.fineract.portfolio.common.domain.DayOfWeekType.INVALID; +import static org.apache.fineract.portfolio.common.domain.PeriodFrequencyType.MONTHS; +import static org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType.CUMULATIVE; +import static org.apache.fineract.portfolio.loanproduct.domain.AmortizationMethod.EQUAL_PRINCIPAL; +import static org.apache.fineract.portfolio.loanproduct.domain.InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD; +import static org.apache.fineract.portfolio.loanproduct.domain.InterestMethod.FLAT; +import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.NONE; +import static org.apache.fineract.portfolio.loanproduct.domain.RepaymentStartDateType.DISBURSEMENT_DATE; +import static org.apache.fineract.util.TimeZoneConstants.ASIA_MANILA_ID; +import static org.apache.fineract.util.TimeZoneConstants.EUROPE_BERLIN_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.junit.context.WithTenantContext; +import org.apache.fineract.junit.context.WithTenantContextExtension; +import org.apache.fineract.junit.system.WithSystemProperty; +import org.apache.fineract.junit.system.WithSystemPropertyExtension; +import org.apache.fineract.junit.timezone.WithSystemTimeZone; +import org.apache.fineract.junit.timezone.WithSystemTimeZoneExtension; +import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.organisation.workingdays.data.AdjustedDateDetailsDTO; +import org.apache.fineract.organisation.workingdays.domain.WorkingDays; +import org.apache.fineract.portfolio.common.domain.DaysInMonthType; +import org.apache.fineract.portfolio.common.domain.DaysInYearType; +import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith({ WithSystemTimeZoneExtension.class, WithTenantContextExtension.class, WithSystemPropertyExtension.class }) +public class DefaultScheduledDateGeneratorTest { + + private DefaultScheduledDateGenerator underTest = new DefaultScheduledDateGenerator(); + + @BeforeEach + public void setUp() { + ConfigurationDomainService cds = Mockito.mock(ConfigurationDomainService.class); + given(cds.getRoundingMode()).willReturn(6); // default + + MoneyHelper moneyHelper = new MoneyHelper(); + ReflectionTestUtils.setField(moneyHelper, "configurationDomainService", cds); + moneyHelper.initialize(); + } + + @Test + @WithSystemTimeZone(EUROPE_BERLIN_ID) + @WithTenantContext(tenantTimeZoneId = EUROPE_BERLIN_ID) + @WithSystemProperty(key = FLOATING_TIMEZONE_PROPERTY_KEY, value = "true") + public void test_AdjustRepaymentDate_Works_WithSameTenant_And_SystemTimeZone() { + // given + HolidayDetailDTO holidayDetailDTO = createHolidayDTO(); + + LocalDate dueRepaymentPeriodDate = LocalDate.of(2023, 11, 26); + + LoanApplicationTerms loanApplicationTerms = createLoanApplicationTerms(dueRepaymentPeriodDate, holidayDetailDTO); + // when + AdjustedDateDetailsDTO result = underTest.adjustRepaymentDate(dueRepaymentPeriodDate, loanApplicationTerms, holidayDetailDTO); + // then + assertThat(result.getChangedScheduleDate()).isEqualTo(LocalDate.of(2023, 11, 26)); + assertThat(result.getChangedActualRepaymentDate()).isEqualTo(LocalDate.of(2023, 11, 26)); + assertThat(result.getNextRepaymentPeriodDueDate()).isEqualTo(LocalDate.of(2023, 12, 26)); + } + + @Test + @WithSystemTimeZone(ASIA_MANILA_ID) + @WithTenantContext(tenantTimeZoneId = EUROPE_BERLIN_ID) + @WithSystemProperty(key = FLOATING_TIMEZONE_PROPERTY_KEY, value = "true") + public void test_AdjustRepaymentDate_Works_WithDifferentTenant_And_SystemTimeZone() { + // given + HolidayDetailDTO holidayDetailDTO = createHolidayDTO(); + + LocalDate dueRepaymentPeriodDate = LocalDate.of(2023, 11, 26); + + LoanApplicationTerms loanApplicationTerms = createLoanApplicationTerms(dueRepaymentPeriodDate, holidayDetailDTO); + // when + AdjustedDateDetailsDTO result = underTest.adjustRepaymentDate(dueRepaymentPeriodDate, loanApplicationTerms, holidayDetailDTO); + // then + assertThat(result.getChangedScheduleDate()).isEqualTo(LocalDate.of(2023, 11, 26)); + assertThat(result.getChangedActualRepaymentDate()).isEqualTo(LocalDate.of(2023, 11, 26)); + assertThat(result.getNextRepaymentPeriodDueDate()).isEqualTo(LocalDate.of(2023, 12, 26)); + } + + private LoanApplicationTerms createLoanApplicationTerms(LocalDate dueRepaymentPeriodDate, HolidayDetailDTO holidayDetailDTO) { + ApplicationCurrency dollarCurrency = new ApplicationCurrency("USD", "US Dollar", 2, 0, "currency.USD", "$"); + Money principalAmount = Money.of(fromApplicationCurrency(dollarCurrency), BigDecimal.valueOf(1000L)); + LocalDate expectedDisbursementDate = LocalDate.of(2023, 10, 26); + + LocalDate submittedOnDate = LocalDate.of(2023, 10, 24); + return LoanApplicationTerms.assembleFrom(dollarCurrency, 1, MONTHS, 1, 1, MONTHS, null, INVALID, EQUAL_PRINCIPAL, FLAT, ZERO, + MONTHS, ZERO, SAME_AS_REPAYMENT_PERIOD, false, principalAmount, expectedDisbursementDate, null, dueRepaymentPeriodDate, + null, null, null, null, null, Money.of(fromApplicationCurrency(dollarCurrency), ZERO), false, null, EMPTY_LIST, + BigDecimal.valueOf(36_000L), null, DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, + null, NONE, null, ZERO, EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, + DISBURSEMENT_DATE, submittedOnDate, false, CUMULATIVE); + } + + private HolidayDetailDTO createHolidayDTO() { + WorkingDays workingDays = new WorkingDays("FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR,SA,SU", MOVE_TO_NEXT_WORKING_DAY.getValue(), + false, false); + HolidayDetailDTO holidayDetailDTO = new HolidayDetailDTO(false, EMPTY_LIST, workingDays, false, false); + return holidayDetailDTO; + } + +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/util/TimeZoneConstants.java b/fineract-provider/src/test/java/org/apache/fineract/util/TimeZoneConstants.java new file mode 100644 index 000000000..b2754364a --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/util/TimeZoneConstants.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.util; + +public final class TimeZoneConstants { + + public static final String ASIA_KOLKATA_ID = "Asia/Kolkata"; + public static final String ASIA_MANILA_ID = "Asia/Manila"; + public static final String EUROPE_BERLIN_ID = "Europe/Berlin"; + + private TimeZoneConstants() {} +}