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..1705caf559b 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,9 +97,9 @@ 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; + LocalDate periodEndDate = interestPostingUpToDate.plusDays(1); final Integer monthOfYear = periodStartDate.getMonthValue(); financialYearBeginningMonth--; if (financialYearBeginningMonth == 0) { @@ -126,37 +127,37 @@ private LocalDate determineInterestPostingPeriodEndDateFrom(final LocalDate peri case INVALID: break; case DAILY: - // produce period end date on current day - periodEndDate = periodStartDate; + // interest posting occurs on the next day after current day + periodEndDate = periodStartDate.plusDays(1); break; case MONTHLY: - // produce period end date on last day of current month - periodEndDate = periodStartDate.with(TemporalAdjusters.lastDayOfMonth()); + // interest posting occurs on the first day of the next month + periodEndDate = periodStartDate.with(TemporalAdjusters.lastDayOfMonth()).plusDays(1); break; case QUATERLY: for (LocalDate quarterlyDate : quarterlyDates) { if (DateUtils.isAfter(quarterlyDate, periodStartDate)) { - periodEndDate = quarterlyDate; + periodEndDate = quarterlyDate.plusDays(1); isEndDateSet = true; break; } } if (!isEndDateSet) { - periodEndDate = quarterlyDates.get(0).plusYears(1).with(TemporalAdjusters.lastDayOfMonth()); + periodEndDate = quarterlyDates.getFirst().plusYears(1).with(TemporalAdjusters.lastDayOfMonth()).plusDays(1); } break; case BIANNUAL: for (LocalDate biannualDate : biannualDates) { if (DateUtils.isAfter(biannualDate, periodStartDate)) { - periodEndDate = biannualDate; + periodEndDate = biannualDate.plusDays(1); isEndDateSet = true; break; } } if (!isEndDateSet) { - periodEndDate = biannualDates.get(0).plusYears(1).with(TemporalAdjusters.lastDayOfMonth()); + periodEndDate = biannualDates.getFirst().plusYears(1).with(TemporalAdjusters.lastDayOfMonth()).plusDays(1); } break; case ANNUAL: @@ -166,14 +167,28 @@ private LocalDate determineInterestPostingPeriodEndDateFrom(final LocalDate peri } else { periodEndDate = periodStartDate.withMonth(financialYearBeginningMonth); } - periodEndDate = periodEndDate.with(TemporalAdjusters.lastDayOfMonth()); + periodEndDate = periodEndDate.with(TemporalAdjusters.lastDayOfMonth()).plusDays(1); + break; + case ANNIVERSARY_MONTHLY: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(1), anniversaryDayOfMonth); + break; + case ANNIVERSARY_QUARTERLY: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(3), anniversaryDayOfMonth); + break; + case ANNIVERSARY_BIANNUAL: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(6), anniversaryDayOfMonth); + break; + case ANNIVERSARY_ANNUAL: + periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(12), anniversaryDayOfMonth); 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-doc/src/docs/en/chapters/features/index.adoc b/fineract-doc/src/docs/en/chapters/features/index.adoc index 042577f9c67..711844f6cbf 100644 --- a/fineract-doc/src/docs/en/chapters/features/index.adoc +++ b/fineract-doc/src/docs/en/chapters/features/index.adoc @@ -18,3 +18,4 @@ include::delayed-schedule-captures.adoc[leveloffset=+1] include::loan-origination-details.adoc[leveloffset=+1] include::working-capital-amortization-schedule.adoc[leveloffset=+1] include::working-capital-credit-balance-refund.adoc[leveloffset=+1] +include::savings-interest-posting.adoc[leveloffset=+1] diff --git a/fineract-doc/src/docs/en/chapters/features/savings-interest-posting.adoc b/fineract-doc/src/docs/en/chapters/features/savings-interest-posting.adoc new file mode 100644 index 00000000000..ed6a02ef1a8 --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/savings-interest-posting.adoc @@ -0,0 +1,113 @@ += Savings Interest Posting + +== Overview + +Apache Fineract supports several *interest posting period types* for savings and fixed-deposit accounts. +The period type determines the calendar interval at the end of which accrued interest is credited (posted) to the account. + +=== Standard Posting Period Types + +[cols="1,1,3", options="header"] +|=== +|Code |Name |Description + +|1 |Daily |Interest is posted every day. +|4 |Monthly |Interest is posted on the first day of each calendar month. +|5 |Quarterly |Interest is posted on the first day of each calendar quarter, aligned to the configured financial-year beginning month. +|6 |Bi-Annual |Interest is posted twice a year, aligned to the configured financial-year beginning month. +|7 |Annual |Interest is posted once a year on the first day of the month that begins the configured financial year. +|=== + +=== Anniversary-Based Posting Period Types + +Anniversary posting periods differ from standard ones in that the posting schedule is anchored to the *day of the month on which the account was activated*, rather than to a fixed calendar boundary (e.g., end of month or end of quarter). + +[cols="1,1,3", options="header"] +|=== +|Code |Name |Description + +|8 |Anniversary Monthly |Interest is posted every month on the same day-of-month as the account activation date. +|9 |Anniversary Quarterly |Interest is posted every three months on the same day-of-month as the account activation date. +|10 |Anniversary Bi-Annual |Interest is posted every six months on the same day-of-month as the account activation date. +|11 |Anniversary Annual |Interest is posted every twelve months on the same day-of-month as the account activation date. +|=== + +== How Anniversary Posting Works + +For all anniversary period types: + +* The *anchor day* is the day-of-month of the account's activation (start interest calculation) date. +* Each subsequent posting date is computed by adding the configured interval (1, 3, 6, or 12 months) to the start of the current period and adjusting the result to the anchor day. +* If the target month has fewer days than the anchor day, the posting date is adjusted to the *last day of that month*. + +=== Posting Date Examples + +**Account opened on January 15 — Anniversary Monthly** + +[cols="1,1,1", options="header"] +|=== +|Period Start |Period End |Posting Date + +|Jan 15 |Feb 14 |Feb 15 +|Feb 15 |Mar 14 |Mar 15 +|Mar 15 |Apr 14 |Apr 15 +|=== + +**Account opened on January 31 — Anniversary Monthly (short-month adjustment)** + +[cols="1,1,1", options="header"] +|=== +|Period Start |Period End |Posting Date + +|Jan 31 |Feb 27 |Feb 28 _(last day of Feb)_ +|Feb 28 |Mar 30 |Mar 31 +|Mar 31 |Apr 29 |Apr 30 _(last day of Apr)_ +|Apr 30 |May 30 |May 31 +|=== + +**Account opened on February 29 (leap year) — Anniversary Annual** + +[cols="1,1,1", options="header"] +|=== +|Period Start |Period End |Posting Date + +|Feb 29, 2024 |Feb 27, 2025 |Feb 28, 2025 _(2025 is not a leap year)_ +|Feb 28, 2025 |Feb 27, 2026 |Feb 28, 2026 +|Feb 28, 2026 |Feb 27, 2027 |Feb 28, 2027 +|=== + +== Configuration + +Anniversary posting period types are configured at the product level and apply to both *Savings Products* and *Fixed Deposit Products*. + +=== API — Create / Update Savings Product + +Pass the numeric code in the `interestPostingPeriodType` field: + +[source,json] +---- +{ + "interestPostingPeriodType": 8 +} +---- + +=== API — Create / Update Fixed Deposit Product + +The same `interestPostingPeriodType` field is used for fixed deposit products. + +[source,json] +---- +{ + "interestPostingPeriodType": 9 +} +---- + +[NOTE] +==== +The financial-year beginning month (`financialYearBeginningMonth`) is not used for anniversary period types. Posting periods are determined solely by the account activation date. +==== + +== Behaviour at Period Boundaries + +* The last posting period of a calculation run may extend *beyond* the `interestPostingUpToDate`. Truncation to the actual balance date is handled downstream by `PostingPeriod`, not by the period-boundary calculation itself. +* When a manual "post interest as on" date falls within a period, that period is split at the manual date, consistent with the behaviour of all other posting period types. 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(); + } +}