diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsPostingInterestPeriodType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsPostingInterestPeriodType.java index d6508e41f6b..5915bf06647 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsPostingInterestPeriodType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsPostingInterestPeriodType.java @@ -31,7 +31,11 @@ public enum SavingsPostingInterestPeriodType { MONTHLY(4, "savingsPostingInterestPeriodType.monthly"), // QUATERLY(5, "savingsPostingInterestPeriodType.quarterly"), // BIANNUAL(6, "savingsPostingInterestPeriodType.biannual"), // - ANNUAL(7, "savingsPostingInterestPeriodType.annual"); // + ANNUAL(7, "savingsPostingInterestPeriodType.annual"), // + ANNIVERSARY_MONTHLY(8, "savingsPostingInterestPeriodType.anniversaryMonthly"), // + ANNIVERSARY_QUARTERLY(9, "savingsPostingInterestPeriodType.anniversaryQuarterly"), // + ANNIVERSARY_BIANNUAL(10, "savingsPostingInterestPeriodType.anniversaryBiAnnual"), // + ANNIVERSARY_ANNUAL(11, "savingsPostingInterestPeriodType.anniversaryAnnual"); // private final Integer value; private final String code; @@ -70,6 +74,14 @@ public static SavingsPostingInterestPeriodType fromInt(final Integer v) { return BIANNUAL; case 7: return ANNUAL; + case 8: + return ANNIVERSARY_MONTHLY; + case 9: + return ANNIVERSARY_QUARTERLY; + case 10: + return ANNIVERSARY_BIANNUAL; + case 11: + return ANNIVERSARY_ANNUAL; default: return INVALID; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java index 0d31101e5a8..f8b854494f0 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java @@ -63,10 +63,11 @@ public List determineInterestPostingPeriods(final LocalDate s LocalDate periodStartDate = startInterestCalculationLocalDate; LocalDate periodEndDate = periodStartDate; LocalDate actualPeriodStartDate = periodStartDate; + final int anniversaryDayOfMonth = startInterestCalculationLocalDate.getDayOfMonth(); while (!DateUtils.isAfter(periodStartDate, interestPostingUpToDate) && !DateUtils.isAfter(periodEndDate, interestPostingUpToDate)) { final LocalDate interestPostingLocalDate = determineInterestPostingPeriodEndDateFrom(periodStartDate, postingPeriodType, - interestPostingUpToDate, financialYearBeginningMonth); + interestPostingUpToDate, financialYearBeginningMonth, anniversaryDayOfMonth); periodEndDate = interestPostingLocalDate.minusDays(1); @@ -96,7 +97,7 @@ public List determineInterestPostingPeriods(final LocalDate s private LocalDate determineInterestPostingPeriodEndDateFrom(final LocalDate periodStartDate, final SavingsPostingInterestPeriodType interestPostingPeriodType, final LocalDate interestPostingUpToDate, - Integer financialYearBeginningMonth) { + Integer financialYearBeginningMonth, final int anniversaryDayOfMonth) { LocalDate periodEndDate = interestPostingUpToDate; final Integer monthOfYear = periodStartDate.getMonthValue(); @@ -168,12 +169,28 @@ private LocalDate determineInterestPostingPeriodEndDateFrom(final LocalDate peri } periodEndDate = periodEndDate.with(TemporalAdjusters.lastDayOfMonth()); break; + case ANNIVERSARY_MONTHLY: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(1), anniversaryDayOfMonth).minusDays(1); + break; + case ANNIVERSARY_QUARTERLY: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(3), anniversaryDayOfMonth).minusDays(1); + break; + case ANNIVERSARY_BIANNUAL: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(6), anniversaryDayOfMonth).minusDays(1); + break; + case ANNIVERSARY_ANNUAL: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(12), anniversaryDayOfMonth).minusDays(1); + break; } // interest posting always occurs on next day after the period end date. periodEndDate = periodEndDate.plusDays(1); return periodEndDate; } + private LocalDate adjustToAnniversaryDay(final LocalDate date, final int anniversaryDay) { + return date.withDayOfMonth(Math.min(anniversaryDay, date.lengthOfMonth())); + } + public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List allPeriods, LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest) { return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil, diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java index 2539af180b8..d70b033904f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java @@ -386,6 +386,22 @@ public static EnumOptionData interestPostingPeriodType(final SavingsPostingInter optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNUAL.getValue().longValue(), codePrefix + SavingsPostingInterestPeriodType.ANNUAL.getCode(), "Annually"); break; + case ANNIVERSARY_MONTHLY: + optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY.getValue().longValue(), + codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY.getCode(), "Anniversary Monthly"); + break; + case ANNIVERSARY_QUARTERLY: + optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY.getValue().longValue(), + codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY.getCode(), "Anniversary Quarterly"); + break; + case ANNIVERSARY_BIANNUAL: + optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL.getValue().longValue(), + codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL.getCode(), "Anniversary BiAnnual"); + break; + case ANNIVERSARY_ANNUAL: + optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL.getValue().longValue(), + codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL.getCode(), "Anniversary Annually"); + break; } return optionData; diff --git a/fineract-core/src/test/java/org/apache/fineract/portfolio/savings/domain/SavingsHelperAnniversaryPostingTest.java b/fineract-core/src/test/java/org/apache/fineract/portfolio/savings/domain/SavingsHelperAnniversaryPostingTest.java new file mode 100644 index 00000000000..9895a9f2dab --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/portfolio/savings/domain/SavingsHelperAnniversaryPostingTest.java @@ -0,0 +1,202 @@ +/** + * 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; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.junit.jupiter.api.Test; + +class SavingsHelperAnniversaryPostingTest { + + private static final Integer FINANCIAL_YEAR_BEGINNING_MONTH = 1; + + private final SavingsHelper savingsHelper = new SavingsHelper(null); + + private static void assertPeriod(LocalDateInterval period, LocalDate expectedStart, LocalDate expectedEnd) { + assertThat(period.startDate()).as("period start").isEqualTo(expectedStart); + assertThat(period.endDate()).as("period end").isEqualTo(expectedEnd); + } + + // ── ANNIVERSARY_MONTHLY ────────────────────────────────────────────────── + + @Test + void anniversaryMonthly_accountOpenedOn15th_periodsAlignToThe15th() { + // Account opened Jan 15 → posts on Feb 15, Mar 15, Apr 15, … + LocalDate start = LocalDate.of(2024, 1, 15); + LocalDate end = LocalDate.of(2024, 3, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(3); + assertPeriod(periods.get(0), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 2, 14)); + assertPeriod(periods.get(1), LocalDate.of(2024, 2, 15), LocalDate.of(2024, 3, 14)); + // last period extends past upToDate — truncation is handled by PostingPeriod, not here + assertPeriod(periods.get(2), LocalDate.of(2024, 3, 15), LocalDate.of(2024, 4, 14)); + } + + @Test + void anniversaryMonthly_accountOpenedOn1st_periodsAlignToFirstOfMonth() { + // Day-1 anniversary: behaves identically to standard monthly-from-start + LocalDate start = LocalDate.of(2024, 1, 1); + LocalDate end = LocalDate.of(2024, 3, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(3); + assertPeriod(periods.get(0), LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 31)); + assertPeriod(periods.get(1), LocalDate.of(2024, 2, 1), LocalDate.of(2024, 2, 29)); // 2024 leap year + assertPeriod(periods.get(2), LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 31)); + } + + @Test + void anniversaryMonthly_accountOpenedOn29th_februaryUsesLastDay() { + // Feb 2025 has 28 days → posting falls on Feb 28 (last day), not Feb 29 + LocalDate start = LocalDate.of(2025, 1, 29); + LocalDate end = LocalDate.of(2025, 4, 30); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(4); + // Jan 29 → Feb 28 posting: period Jan 29 – Feb 27 + assertPeriod(periods.get(0), LocalDate.of(2025, 1, 29), LocalDate.of(2025, 2, 27)); + // Feb 28 → Mar 29 posting: period Feb 28 – Mar 28 + assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2025, 3, 28)); + // Mar 29 → Apr 29 posting: period Mar 29 – Apr 28 + assertPeriod(periods.get(2), LocalDate.of(2025, 3, 29), LocalDate.of(2025, 4, 28)); + // Apr 29 → May 29 posting: period Apr 29 – May 28 (extends past upToDate Apr 30) + assertPeriod(periods.get(3), LocalDate.of(2025, 4, 29), LocalDate.of(2025, 5, 28)); + } + + @Test + void anniversaryMonthly_accountOpenedOn31st_shortMonthsUseLastDay() { + // Jan 31 → Feb 28 (28 days), then Mar 31, Apr 30, May 31, Jun 30, … + LocalDate start = LocalDate.of(2025, 1, 31); + LocalDate end = LocalDate.of(2025, 5, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(5); + assertPeriod(periods.get(0), LocalDate.of(2025, 1, 31), LocalDate.of(2025, 2, 27)); // Feb 28 - 1 + assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2025, 3, 30)); // Mar 31 - 1 + assertPeriod(periods.get(2), LocalDate.of(2025, 3, 31), LocalDate.of(2025, 4, 29)); // Apr 30 - 1 + assertPeriod(periods.get(3), LocalDate.of(2025, 4, 30), LocalDate.of(2025, 5, 30)); // May 31 - 1 + // last period extends past upToDate May 31 → May 31 – Jun 29 + assertPeriod(periods.get(4), LocalDate.of(2025, 5, 31), LocalDate.of(2025, 6, 29)); // Jun 30 - 1 + } + + // ── ANNIVERSARY_QUARTERLY ──────────────────────────────────────────────── + + @Test + void anniversaryQuarterly_accountOpenedOn15th_periodsAlignToThe15th() { + // Account opened Jan 15 → posts every 3 months on the 15th + LocalDate start = LocalDate.of(2024, 1, 15); + LocalDate end = LocalDate.of(2024, 12, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(4); + assertPeriod(periods.get(0), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 4, 14)); + assertPeriod(periods.get(1), LocalDate.of(2024, 4, 15), LocalDate.of(2024, 7, 14)); + assertPeriod(periods.get(2), LocalDate.of(2024, 7, 15), LocalDate.of(2024, 10, 14)); + // last period extends past Dec 31 → Oct 15 – Jan 14 2025 + assertPeriod(periods.get(3), LocalDate.of(2024, 10, 15), LocalDate.of(2025, 1, 14)); + } + + @Test + void anniversaryQuarterly_accountOpenedOn29th_februaryQuarterUsesLastDay() { + // Nov 29 + 3 months = Feb 28 (Feb 2025 has 28 days) + LocalDate start = LocalDate.of(2024, 11, 29); + LocalDate end = LocalDate.of(2025, 5, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(3); + // Nov 29 → Feb 28 posting: period Nov 29 – Feb 27 + assertPeriod(periods.get(0), LocalDate.of(2024, 11, 29), LocalDate.of(2025, 2, 27)); + // Feb 28 → May 29 posting: period Feb 28 – May 28 + assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2025, 5, 28)); + // May 29 → Aug 29 posting: period May 29 – Aug 28 (extends past upToDate May 31) + assertPeriod(periods.get(2), LocalDate.of(2025, 5, 29), LocalDate.of(2025, 8, 28)); + } + + // ── ANNIVERSARY_BIANNUAL ───────────────────────────────────────────────── + + @Test + void anniversaryBiAnnual_accountOpenedOn15th_periodsAlignToThe15th() { + // Account opened Jan 15 → posts every 6 months on the 15th + LocalDate start = LocalDate.of(2024, 1, 15); + LocalDate end = LocalDate.of(2025, 1, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(3); + assertPeriod(periods.get(0), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 7, 14)); + assertPeriod(periods.get(1), LocalDate.of(2024, 7, 15), LocalDate.of(2025, 1, 14)); + // last period extends past Jan 31 → Jan 15 2025 – Jul 14 2025 + assertPeriod(periods.get(2), LocalDate.of(2025, 1, 15), LocalDate.of(2025, 7, 14)); + } + + // ── ANNIVERSARY_ANNUAL ─────────────────────────────────────────────────── + + @Test + void anniversaryAnnual_accountOpenedOn15th_periodsAlignToThe15th() { + // Account opened Mar 15 → posts annually on Mar 15 + LocalDate start = LocalDate.of(2024, 3, 15); + LocalDate end = LocalDate.of(2026, 3, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(3); + assertPeriod(periods.get(0), LocalDate.of(2024, 3, 15), LocalDate.of(2025, 3, 14)); + assertPeriod(periods.get(1), LocalDate.of(2025, 3, 15), LocalDate.of(2026, 3, 14)); + // last period extends past Mar 31 2026 → Mar 15 2026 – Mar 14 2027 + assertPeriod(periods.get(2), LocalDate.of(2026, 3, 15), LocalDate.of(2027, 3, 14)); + } + + @Test + void anniversaryAnnual_accountOpenedOnFeb29_leapYearHandling() { + // Feb 29 2024 (leap) → next year Feb has 28 days → post on Feb 28 + LocalDate start = LocalDate.of(2024, 2, 29); + LocalDate end = LocalDate.of(2026, 3, 31); + + List periods = savingsHelper.determineInterestPostingPeriods(start, end, + SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList()); + + assertThat(periods).hasSize(3); + // Feb 29 2024 → Feb 28 2025 posting: period Feb 29 2024 – Feb 27 2025 + assertPeriod(periods.get(0), LocalDate.of(2024, 2, 29), LocalDate.of(2025, 2, 27)); + // Feb 28 2025 → Feb 28 2026 posting: period Feb 28 2025 – Feb 27 2026 + assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2026, 2, 27)); + // last period extends past Mar 31 2026 → Feb 28 2026 – Feb 27 2027 + assertPeriod(periods.get(2), LocalDate.of(2026, 2, 28), LocalDate.of(2027, 2, 27)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImpl.java index f4fb6238a1b..53d38bd6875 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImpl.java @@ -65,16 +65,17 @@ public Collection retrieveCompoundingInterestPeriodTypeOptions() } @Override - public Collection retrieveInterestPostingPeriodTypeOptions() { - final List allowedOptions = Arrays.asList( - SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.DAILY), // + public List retrieveInterestPostingPeriodTypeOptions() { + return Arrays.asList(SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.DAILY), // SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.MONTHLY), // SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.QUATERLY), // SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.BIANNUAL), // - SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNUAL) // + SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNUAL), // + SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY), // + SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY), // + SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL), // + SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL) // ); - - return allowedOptions; } @Override diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImplTest.java new file mode 100644 index 00000000000..a13012448d7 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImplTest.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.junit.jupiter.api.Test; + +class SavingsDropdownReadPlatformServiceImplTest { + + private final SavingsDropdownReadPlatformServiceImpl service = new SavingsDropdownReadPlatformServiceImpl(); + + @Test + void retrieveInterestPostingPeriodTypeOptions_shouldIncludeAllAnniversaryTypes() { + List options = service.retrieveInterestPostingPeriodTypeOptions(); + List ids = options.stream().map(EnumOptionData::getId).collect(Collectors.toList()); + + assertThat(ids).contains((long) SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY.getValue(), + (long) SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY.getValue(), + (long) SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL.getValue(), + (long) SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL.getValue()); + } + + @Test + void retrieveInterestPostingPeriodTypeOptions_shouldIncludeAllOriginalTypes() { + List options = service.retrieveInterestPostingPeriodTypeOptions(); + List ids = options.stream().map(EnumOptionData::getId).collect(Collectors.toList()); + + assertThat(ids).contains((long) SavingsPostingInterestPeriodType.DAILY.getValue(), + (long) SavingsPostingInterestPeriodType.MONTHLY.getValue(), (long) SavingsPostingInterestPeriodType.QUATERLY.getValue(), + (long) SavingsPostingInterestPeriodType.BIANNUAL.getValue(), (long) SavingsPostingInterestPeriodType.ANNUAL.getValue()); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProduct.java index 216770856c3..40519c2e5ef 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProduct.java @@ -302,6 +302,24 @@ public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidator SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, SavingsCompoundingInterestPeriodType.ANNUAL })); + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, + Arrays.asList(new SavingsCompoundingInterestPeriodType[] { SavingsCompoundingInterestPeriodType.DAILY, + SavingsCompoundingInterestPeriodType.MONTHLY })); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY, + Arrays.asList(new SavingsCompoundingInterestPeriodType[] { SavingsCompoundingInterestPeriodType.DAILY, + SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY })); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL, + Arrays.asList(new SavingsCompoundingInterestPeriodType[] { SavingsCompoundingInterestPeriodType.DAILY, + SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY, + SavingsCompoundingInterestPeriodType.BI_ANNUAL })); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL, + Arrays.asList(new SavingsCompoundingInterestPeriodType[] { SavingsCompoundingInterestPeriodType.DAILY, + SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY, + SavingsCompoundingInterestPeriodType.BI_ANNUAL, SavingsCompoundingInterestPeriodType.ANNUAL })); + SavingsPostingInterestPeriodType savingsPostingInterestPeriodType = SavingsPostingInterestPeriodType .fromInt(interestPostingPeriodType); SavingsCompoundingInterestPeriodType savingsCompoundingInterestPeriodType = SavingsCompoundingInterestPeriodType diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 92cc6aaace6..a75415c7508 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -3337,6 +3337,22 @@ public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidator SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, SavingsCompoundingInterestPeriodType.ANNUAL)); + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, + SavingsCompoundingInterestPeriodType.ANNUAL)); + SavingsPostingInterestPeriodType savingsPostingInterestPeriodType = SavingsPostingInterestPeriodType .fromInt(interestPostingPeriodType); SavingsCompoundingInterestPeriodType savingsCompoundingInterestPeriodType = SavingsCompoundingInterestPeriodType @@ -3345,7 +3361,6 @@ public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidator if (postingtoCompoundMap.get(savingsPostingInterestPeriodType) == null) { baseDataValidator.failWithCodeNoParameterAddedToErrorCode("posting.period.type.is.less.than.compound.period.type", savingsPostingInterestPeriodType.name(), savingsCompoundingInterestPeriodType.name()); - } } diff --git a/fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProductAnniversaryValidationTest.java b/fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProductAnniversaryValidationTest.java new file mode 100644 index 00000000000..89585e644bc --- /dev/null +++ b/fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/domain/FixedDepositProductAnniversaryValidationTest.java @@ -0,0 +1,88 @@ +/** + * 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; + +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.ANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.BI_ANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.DAILY; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.MONTHLY; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.QUATERLY; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class FixedDepositProductAnniversaryValidationTest { + + private FixedDepositProduct productWith(SavingsPostingInterestPeriodType posting, SavingsCompoundingInterestPeriodType compounding) { + FixedDepositProduct product = new FixedDepositProduct() {}; + product.interestPostingPeriodType = posting.getValue(); + product.interestCompoundingPeriodType = compounding.getValue(); + return product; + } + + private List validate(SavingsPostingInterestPeriodType posting, SavingsCompoundingInterestPeriodType compounding) { + List errors = new ArrayList<>(); + productWith(posting, compounding) + .validateInterestPostingAndCompoundingPeriodTypes(new DataValidatorBuilder(errors).resource("test")); + return errors; + } + + static Stream validCombinations() { + return Stream.of(Arguments.of(ANNIVERSARY_MONTHLY, DAILY), Arguments.of(ANNIVERSARY_MONTHLY, MONTHLY), + Arguments.of(ANNIVERSARY_QUARTERLY, DAILY), Arguments.of(ANNIVERSARY_QUARTERLY, MONTHLY), + Arguments.of(ANNIVERSARY_QUARTERLY, QUATERLY), Arguments.of(ANNIVERSARY_BIANNUAL, DAILY), + Arguments.of(ANNIVERSARY_BIANNUAL, MONTHLY), Arguments.of(ANNIVERSARY_BIANNUAL, QUATERLY), + Arguments.of(ANNIVERSARY_BIANNUAL, BI_ANNUAL), Arguments.of(ANNIVERSARY_ANNUAL, DAILY), + Arguments.of(ANNIVERSARY_ANNUAL, MONTHLY), Arguments.of(ANNIVERSARY_ANNUAL, QUATERLY), + Arguments.of(ANNIVERSARY_ANNUAL, BI_ANNUAL), Arguments.of(ANNIVERSARY_ANNUAL, ANNUAL)); + } + + static Stream invalidCombinations() { + return Stream.of(Arguments.of(ANNIVERSARY_MONTHLY, QUATERLY), Arguments.of(ANNIVERSARY_MONTHLY, BI_ANNUAL), + Arguments.of(ANNIVERSARY_MONTHLY, ANNUAL), Arguments.of(ANNIVERSARY_QUARTERLY, BI_ANNUAL), + Arguments.of(ANNIVERSARY_QUARTERLY, ANNUAL), Arguments.of(ANNIVERSARY_BIANNUAL, ANNUAL)); + } + + @ParameterizedTest + @MethodSource("validCombinations") + void validCompoundingCombination_shouldProduceNoErrors(SavingsPostingInterestPeriodType posting, + SavingsCompoundingInterestPeriodType compounding) { + assertThat(validate(posting, compounding)).isEmpty(); + } + + @ParameterizedTest + @MethodSource("invalidCombinations") + void compoundingLongerThanPosting_shouldProduceValidationError(SavingsPostingInterestPeriodType posting, + SavingsCompoundingInterestPeriodType compounding) { + assertThat(validate(posting, compounding)).isNotEmpty(); + } +} diff --git a/fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAnniversaryValidationTest.java b/fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAnniversaryValidationTest.java new file mode 100644 index 00000000000..366ce176227 --- /dev/null +++ b/fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAnniversaryValidationTest.java @@ -0,0 +1,83 @@ +/** + * 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; + +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.ANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.BI_ANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.DAILY; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.MONTHLY; +import static org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType.QUATERLY; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY; +import static org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * SavingsAccount.validateInterestPostingAndCompoundingPeriodTypes only checks that the posting type is a recognized + * type (present in the map). Unlike FixedDepositProduct, it does NOT restrict which compounding types are allowed per + * posting type, so any compounding type is valid as long as the posting type itself is recognized. + */ +class SavingsAccountAnniversaryValidationTest { + + private SavingsAccount accountWith(SavingsPostingInterestPeriodType posting, SavingsCompoundingInterestPeriodType compounding) { + SavingsAccount account = new SavingsAccount() {}; + account.interestPostingPeriodType = posting.getValue(); + account.interestCompoundingPeriodType = compounding.getValue(); + return account; + } + + private List validate(SavingsPostingInterestPeriodType posting, SavingsCompoundingInterestPeriodType compounding) { + List errors = new ArrayList<>(); + accountWith(posting, compounding) + .validateInterestPostingAndCompoundingPeriodTypes(new DataValidatorBuilder(errors).resource("test")); + return errors; + } + + static Stream allAnniversaryTypesWithAllCompoundingTypes() { + return Stream.of(Arguments.of(ANNIVERSARY_MONTHLY, DAILY), Arguments.of(ANNIVERSARY_MONTHLY, MONTHLY), + Arguments.of(ANNIVERSARY_MONTHLY, QUATERLY), Arguments.of(ANNIVERSARY_MONTHLY, BI_ANNUAL), + Arguments.of(ANNIVERSARY_MONTHLY, ANNUAL), Arguments.of(ANNIVERSARY_QUARTERLY, DAILY), + Arguments.of(ANNIVERSARY_QUARTERLY, MONTHLY), Arguments.of(ANNIVERSARY_QUARTERLY, QUATERLY), + Arguments.of(ANNIVERSARY_QUARTERLY, BI_ANNUAL), Arguments.of(ANNIVERSARY_QUARTERLY, ANNUAL), + Arguments.of(ANNIVERSARY_BIANNUAL, DAILY), Arguments.of(ANNIVERSARY_BIANNUAL, MONTHLY), + Arguments.of(ANNIVERSARY_BIANNUAL, QUATERLY), Arguments.of(ANNIVERSARY_BIANNUAL, BI_ANNUAL), + Arguments.of(ANNIVERSARY_BIANNUAL, ANNUAL), Arguments.of(ANNIVERSARY_ANNUAL, DAILY), + Arguments.of(ANNIVERSARY_ANNUAL, MONTHLY), Arguments.of(ANNIVERSARY_ANNUAL, QUATERLY), + Arguments.of(ANNIVERSARY_ANNUAL, BI_ANNUAL), Arguments.of(ANNIVERSARY_ANNUAL, ANNUAL)); + } + + @ParameterizedTest + @MethodSource("allAnniversaryTypesWithAllCompoundingTypes") + void anniversaryPostingType_withAnyCompounding_shouldBeRecognizedAndProduceNoErrors(SavingsPostingInterestPeriodType posting, + SavingsCompoundingInterestPeriodType compounding) { + assertThat(validate(posting, compounding)).isEmpty(); + } +}