diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java index 65130e998c..92ef5e3e09 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.savings.domain.interest; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.YearMonth; import java.time.temporal.ChronoField; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; @@ -492,7 +493,7 @@ public final class PostingPeriod { periodEndDate = periodStartDate.withMonth(previousMonth); periodEndDate = periodEndDate.with(TemporalAdjusters.lastDayOfMonth()); if (DateUtils.isBefore(periodEndDate, periodStartDate)) { - periodEndDate = periodEndDate.plusYears(1); + periodEndDate = periodEndDate.plusYears(1).with(TemporalAdjusters.lastDayOfMonth()); } break; @@ -508,7 +509,7 @@ public final class PostingPeriod { private static LocalDate getPeriodEndDate(LocalDate periodEndDate, int previousMonth, int periodsInMonth, LocalDate periodStartDate) { int year = periodStartDate.get(ChronoField.YEAR_OF_ERA); int monthofYear = periodStartDate.getMonthValue(); - LocalDate date = DateUtils.getBusinessLocalDate(); + LocalDate date = periodStartDate.withDayOfMonth(1); TreeSet monthSet = new TreeSet<>(); date = date.withMonth(previousMonth); monthSet.add(date.getMonthValue()); @@ -527,14 +528,13 @@ public final class PostingPeriod { for (Integer month : monthSet) { if (monthofYear <= month.intValue()) { - periodEndDate = LocalDate.of(year, month, DateUtils.getBusinessLocalDate().withMonth(month).lengthOfMonth()); + periodEndDate = YearMonth.of(year, month).atEndOfMonth(); notInRange = false; break; } } if (notInRange) { - periodEndDate = LocalDate.of(year + 1, monthSet.first(), - DateUtils.getBusinessLocalDate().withMonth(monthSet.first()).lengthOfMonth()); + periodEndDate = YearMonth.of(year + 1, monthSet.first()).atEndOfMonth(); } return periodEndDate; } diff --git a/fineract-core/src/test/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriodCalendarInvariantTest.java b/fineract-core/src/test/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriodCalendarInvariantTest.java new file mode 100644 index 0000000000..fa69b99b88 --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriodCalendarInvariantTest.java @@ -0,0 +1,175 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain.interest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +class PostingPeriodCalendarInvariantTest { + + private static final String TENANT = "calendar-invariant-audit"; + private static final MonetaryCurrency CURRENCY = new MonetaryCurrency("USD", 2, null); + private FineractPlatformTenant originalTenant; + private HashMap originalBusinessDates; + + @BeforeEach + void setUp() { + originalTenant = ThreadLocalContextUtil.getTenant(); + try { + originalBusinessDates = ThreadLocalContextUtil.getBusinessDates(); + } catch (IllegalArgumentException ignored) { + originalBusinessDates = new HashMap<>(); + } + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(99L, TENANT, "Calendar audit", "UTC", null)); + MoneyHelper.initializeTenantRoundingMode(TENANT, 4); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.setTenant(originalTenant); + ThreadLocalContextUtil.setBusinessDates(originalBusinessDates); + MoneyHelper.clearCacheForTenant(TENANT); + } + + @ParameterizedTest + @CsvSource({ "QUATERLY, 2024-06-15, 2025-02-28", "BI_ANNUAL, 2024-06-15, 2025-02-28", "QUATERLY, 2024-06-15, 2023-02-28", + "BI_ANNUAL, 2024-06-15, 2023-02-28", "QUATERLY, 2025-06-15, 2025-02-28", "BI_ANNUAL, 2025-06-15, 2025-02-28" }) + void validNonLeapFebruaryMustNotDependOnBusinessYear(SavingsCompoundingInterestPeriodType type, LocalDate businessDate, + LocalDate date) { + setBusinessDate(businessDate); + PostingPeriod period = posting(date, type); + CompoundInterestValues values = new CompoundInterestValues(BigDecimal.ZERO, BigDecimal.ZERO); + period.calculateInterest(values); + assertEquals(0, new BigDecimal("1.00").compareTo(period.getInterestEarned().getAmount())); + assertEquals(0, BigDecimal.ONE.compareTo(values.getcompoundedInterest())); + assertEquals(0, BigDecimal.ZERO.compareTo(values.getuncompoundedInterest())); + } + + @ParameterizedTest + @EnumSource(value = SavingsCompoundingInterestPeriodType.class, names = { "QUATERLY", "BI_ANNUAL" }) + void leapFebruaryMustNotCompoundOneDayEarly(SavingsCompoundingInterestPeriodType type) { + LocalDate date = LocalDate.of(2024, 2, 28); + for (LocalDate businessDate : List.of(LocalDate.of(2024, 6, 15), LocalDate.of(2026, 6, 15))) { + setBusinessDate(businessDate); + PostingPeriod period = posting(date, type); + CompoundInterestValues values = new CompoundInterestValues(BigDecimal.ZERO, BigDecimal.ZERO); + period.calculateInterest(values); + assertEquals(0, BigDecimal.ZERO.compareTo(values.getcompoundedInterest()), + "February 28 is not the last day of February 2024; businessDate=" + businessDate); + assertEquals(0, BigDecimal.ONE.compareTo(values.getuncompoundedInterest())); + } + } + + @ParameterizedTest + @CsvSource({ "2023-03-01, 2024-02-29, 366.00", "2024-03-01, 2025-02-28, 365.00", "2025-03-01, 2026-02-28, 365.00" }) + void annualCompoundingMustUseMonthEndInTargetYear(LocalDate start, LocalDate end, BigDecimal expectedInterest) { + setBusinessDate(LocalDate.of(2026, 9, 7)); + PostingPeriod period = PostingPeriod.createFrom(LocalDateInterval.create(start, end), Money.of(CURRENCY, new BigDecimal("1000")), + List.of(), CURRENCY, SavingsCompoundingInterestPeriodType.ANNUAL, SavingsInterestCalculationType.DAILY_BALANCE, + new BigDecimal("0.365"), 365L, end, List.of(), false, Money.zero(CURRENCY), false, false, 3); + period.calculateInterest(new CompoundInterestValues(BigDecimal.ZERO, BigDecimal.ZERO)); + assertEquals(0, expectedInterest.compareTo(period.getInterestEarned().getAmount()), + "Interest before the first annual compounding boundary must be principal * rate * days / 365; actual=" + + period.getInterestEarned().getAmount()); + assertEquals(1, period.getCompoundingPeriods().size(), "One fiscal year must form one compounding period"); + } + + @ParameterizedTest + @EnumSource(value = SavingsCompoundingInterestPeriodType.class, names = { "QUATERLY", "BI_ANNUAL" }) + void nextYearFebruaryBoundaryMustRemainIndependentOfBusinessYear(SavingsCompoundingInterestPeriodType type) { + LocalDate start = LocalDate.of(2023, 12, 1); + LocalDate end = LocalDate.of(2024, 2, 28); + for (LocalDate businessDate : List.of(LocalDate.of(2024, 6, 15), LocalDate.of(2026, 6, 15))) { + setBusinessDate(businessDate); + PostingPeriod period = PostingPeriod.createFrom(LocalDateInterval.create(start, end), + Money.of(CURRENCY, new BigDecimal("1000")), List.of(), CURRENCY, type, SavingsInterestCalculationType.DAILY_BALANCE, + new BigDecimal("0.365"), 365L, end, List.of(), false, Money.zero(CURRENCY), false, false, 3); + CompoundInterestValues values = new CompoundInterestValues(BigDecimal.ZERO, BigDecimal.ZERO); + period.calculateInterest(values); + assertEquals(0, BigDecimal.ZERO.compareTo(values.getcompoundedInterest()), + "The first fiscal boundary is February 29, 2024; businessDate=" + businessDate); + assertEquals(0, new BigDecimal("90").compareTo(values.getuncompoundedInterest())); + } + } + + @ParameterizedTest(name = "{0}, fiscal month={1}, start={2}, business date={3}, months={4}") + @MethodSource("fiscalCycles") + void firstFiscalCycleMustEqualSimpleInterestBeforeItsOnlyCompoundingBoundary(SavingsCompoundingInterestPeriodType type, int fiscalMonth, + LocalDate start, LocalDate businessDate, int months) { + setBusinessDate(businessDate); + LocalDate end = start.plusMonths(months).minusDays(1); + PostingPeriod period = PostingPeriod.createFrom(LocalDateInterval.create(start, end), Money.of(CURRENCY, new BigDecimal("1000")), + List.of(), CURRENCY, type, SavingsInterestCalculationType.DAILY_BALANCE, new BigDecimal("0.365"), 365L, end, List.of(), + false, Money.zero(CURRENCY), false, false, fiscalMonth); + period.calculateInterest(new CompoundInterestValues(BigDecimal.ZERO, BigDecimal.ZERO)); + // With principal 1000, annual rate .365 and denominator 365, each day earns exactly 1. + BigDecimal expectedInterest = BigDecimal.valueOf(ChronoUnit.DAYS.between(start, end.plusDays(1))); + assertEquals(0, expectedInterest.compareTo(period.getInterestEarned().getAmount()), + "No interest may compound before the end of the first complete fiscal cycle"); + assertEquals(1, period.getCompoundingPeriods().size()); + } + + private static List fiscalCycles() { + List cases = new ArrayList<>(); + for (int year : List.of(2023, 2024)) { + for (int fiscalMonth = 1; fiscalMonth <= 12; fiscalMonth++) { + for (LocalDate businessDate : List.of(LocalDate.of(2024, 6, 15), LocalDate.of(2026, 6, 15))) { + LocalDate start = LocalDate.of(year, fiscalMonth, 1); + cases.add(Arguments.of(SavingsCompoundingInterestPeriodType.QUATERLY, fiscalMonth, start, businessDate, 3)); + cases.add(Arguments.of(SavingsCompoundingInterestPeriodType.BI_ANNUAL, fiscalMonth, start, businessDate, 6)); + cases.add(Arguments.of(SavingsCompoundingInterestPeriodType.ANNUAL, fiscalMonth, start, businessDate, 12)); + } + } + } + return cases; + } + + private static PostingPeriod posting(LocalDate date, SavingsCompoundingInterestPeriodType type) { + return PostingPeriod.createFrom(LocalDateInterval.create(date, date), Money.of(CURRENCY, new BigDecimal("1000")), List.of(), + CURRENCY, type, SavingsInterestCalculationType.DAILY_BALANCE, new BigDecimal("0.365"), 365L, date, List.of(), false, + Money.zero(CURRENCY), false, false, 3); + } + + private static void setBusinessDate(LocalDate date) { + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, date))); + } +}