Skip to content

Commit 2081a59

Browse files
committed
FINERACT-1185: Add anniversary interest posting period types
1 parent 60acf85 commit 2081a59

10 files changed

Lines changed: 515 additions & 10 deletions

File tree

fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsPostingInterestPeriodType.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ public enum SavingsPostingInterestPeriodType {
3131
MONTHLY(4, "savingsPostingInterestPeriodType.monthly"), //
3232
QUATERLY(5, "savingsPostingInterestPeriodType.quarterly"), //
3333
BIANNUAL(6, "savingsPostingInterestPeriodType.biannual"), //
34-
ANNUAL(7, "savingsPostingInterestPeriodType.annual"); //
34+
ANNUAL(7, "savingsPostingInterestPeriodType.annual"), //
35+
ANNIVERSARY_MONTHLY(8, "savingsPostingInterestPeriodType.anniversaryMonthly"), //
36+
ANNIVERSARY_QUARTERLY(9, "savingsPostingInterestPeriodType.anniversaryQuarterly"), //
37+
ANNIVERSARY_BIANNUAL(10, "savingsPostingInterestPeriodType.anniversaryBiAnnual"), //
38+
ANNIVERSARY_ANNUAL(11, "savingsPostingInterestPeriodType.anniversaryAnnual"); //
3539

3640
private final Integer value;
3741
private final String code;
@@ -70,6 +74,14 @@ public static SavingsPostingInterestPeriodType fromInt(final Integer v) {
7074
return BIANNUAL;
7175
case 7:
7276
return ANNUAL;
77+
case 8:
78+
return ANNIVERSARY_MONTHLY;
79+
case 9:
80+
return ANNIVERSARY_QUARTERLY;
81+
case 10:
82+
return ANNIVERSARY_BIANNUAL;
83+
case 11:
84+
return ANNIVERSARY_ANNUAL;
7385
default:
7486
return INVALID;
7587
}

fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ public List<LocalDateInterval> determineInterestPostingPeriods(final LocalDate s
6363
LocalDate periodStartDate = startInterestCalculationLocalDate;
6464
LocalDate periodEndDate = periodStartDate;
6565
LocalDate actualPeriodStartDate = periodStartDate;
66+
final int anniversaryDayOfMonth = startInterestCalculationLocalDate.getDayOfMonth();
6667

6768
while (!DateUtils.isAfter(periodStartDate, interestPostingUpToDate) && !DateUtils.isAfter(periodEndDate, interestPostingUpToDate)) {
6869
final LocalDate interestPostingLocalDate = determineInterestPostingPeriodEndDateFrom(periodStartDate, postingPeriodType,
69-
interestPostingUpToDate, financialYearBeginningMonth);
70+
interestPostingUpToDate, financialYearBeginningMonth, anniversaryDayOfMonth);
7071

7172
periodEndDate = interestPostingLocalDate.minusDays(1);
7273

@@ -96,7 +97,7 @@ public List<LocalDateInterval> determineInterestPostingPeriods(final LocalDate s
9697

9798
private LocalDate determineInterestPostingPeriodEndDateFrom(final LocalDate periodStartDate,
9899
final SavingsPostingInterestPeriodType interestPostingPeriodType, final LocalDate interestPostingUpToDate,
99-
Integer financialYearBeginningMonth) {
100+
Integer financialYearBeginningMonth, final int anniversaryDayOfMonth) {
100101

101102
LocalDate periodEndDate = interestPostingUpToDate;
102103
final Integer monthOfYear = periodStartDate.getMonthValue();
@@ -168,12 +169,28 @@ private LocalDate determineInterestPostingPeriodEndDateFrom(final LocalDate peri
168169
}
169170
periodEndDate = periodEndDate.with(TemporalAdjusters.lastDayOfMonth());
170171
break;
172+
case ANNIVERSARY_MONTHLY:
173+
periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(1), anniversaryDayOfMonth).minusDays(1);
174+
break;
175+
case ANNIVERSARY_QUARTERLY:
176+
periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(3), anniversaryDayOfMonth).minusDays(1);
177+
break;
178+
case ANNIVERSARY_BIANNUAL:
179+
periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(6), anniversaryDayOfMonth).minusDays(1);
180+
break;
181+
case ANNIVERSARY_ANNUAL:
182+
periodEndDate = adjustToAnniversaryDay(periodStartDate.plusMonths(12), anniversaryDayOfMonth).minusDays(1);
183+
break;
171184
}
172185
// interest posting always occurs on next day after the period end date.
173186
periodEndDate = periodEndDate.plusDays(1);
174187
return periodEndDate;
175188
}
176189

190+
private LocalDate adjustToAnniversaryDay(final LocalDate date, final int anniversaryDay) {
191+
return date.withDayOfMonth(Math.min(anniversaryDay, date.lengthOfMonth()));
192+
}
193+
177194
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
178195
LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest) {
179196
return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil,

fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,22 @@ public static EnumOptionData interestPostingPeriodType(final SavingsPostingInter
386386
optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNUAL.getValue().longValue(),
387387
codePrefix + SavingsPostingInterestPeriodType.ANNUAL.getCode(), "Annually");
388388
break;
389+
case ANNIVERSARY_MONTHLY:
390+
optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY.getValue().longValue(),
391+
codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY.getCode(), "Anniversary Monthly");
392+
break;
393+
case ANNIVERSARY_QUARTERLY:
394+
optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY.getValue().longValue(),
395+
codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY.getCode(), "Anniversary Quarterly");
396+
break;
397+
case ANNIVERSARY_BIANNUAL:
398+
optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL.getValue().longValue(),
399+
codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL.getCode(), "Anniversary BiAnnual");
400+
break;
401+
case ANNIVERSARY_ANNUAL:
402+
optionData = new EnumOptionData(SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL.getValue().longValue(),
403+
codePrefix + SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL.getCode(), "Anniversary Annually");
404+
break;
389405
}
390406

391407
return optionData;
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.savings.domain;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
23+
import java.time.LocalDate;
24+
import java.util.Collections;
25+
import java.util.List;
26+
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
27+
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
28+
import org.junit.jupiter.api.Test;
29+
30+
class SavingsHelperAnniversaryPostingTest {
31+
32+
private static final Integer FINANCIAL_YEAR_BEGINNING_MONTH = 1;
33+
34+
private final SavingsHelper savingsHelper = new SavingsHelper(null);
35+
36+
private static void assertPeriod(LocalDateInterval period, LocalDate expectedStart, LocalDate expectedEnd) {
37+
assertThat(period.startDate()).as("period start").isEqualTo(expectedStart);
38+
assertThat(period.endDate()).as("period end").isEqualTo(expectedEnd);
39+
}
40+
41+
// ── ANNIVERSARY_MONTHLY ──────────────────────────────────────────────────
42+
43+
@Test
44+
void anniversaryMonthly_accountOpenedOn15th_periodsAlignToThe15th() {
45+
// Account opened Jan 15 → posts on Feb 15, Mar 15, Apr 15, …
46+
LocalDate start = LocalDate.of(2024, 1, 15);
47+
LocalDate end = LocalDate.of(2024, 3, 31);
48+
49+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
50+
SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
51+
52+
assertThat(periods).hasSize(3);
53+
assertPeriod(periods.get(0), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 2, 14));
54+
assertPeriod(periods.get(1), LocalDate.of(2024, 2, 15), LocalDate.of(2024, 3, 14));
55+
// last period extends past upToDate — truncation is handled by PostingPeriod, not here
56+
assertPeriod(periods.get(2), LocalDate.of(2024, 3, 15), LocalDate.of(2024, 4, 14));
57+
}
58+
59+
@Test
60+
void anniversaryMonthly_accountOpenedOn1st_periodsAlignToFirstOfMonth() {
61+
// Day-1 anniversary: behaves identically to standard monthly-from-start
62+
LocalDate start = LocalDate.of(2024, 1, 1);
63+
LocalDate end = LocalDate.of(2024, 3, 31);
64+
65+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
66+
SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
67+
68+
assertThat(periods).hasSize(3);
69+
assertPeriod(periods.get(0), LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 31));
70+
assertPeriod(periods.get(1), LocalDate.of(2024, 2, 1), LocalDate.of(2024, 2, 29)); // 2024 leap year
71+
assertPeriod(periods.get(2), LocalDate.of(2024, 3, 1), LocalDate.of(2024, 3, 31));
72+
}
73+
74+
@Test
75+
void anniversaryMonthly_accountOpenedOn29th_februaryUsesLastDay() {
76+
// Feb 2025 has 28 days → posting falls on Feb 28 (last day), not Feb 29
77+
LocalDate start = LocalDate.of(2025, 1, 29);
78+
LocalDate end = LocalDate.of(2025, 4, 30);
79+
80+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
81+
SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
82+
83+
assertThat(periods).hasSize(4);
84+
// Jan 29 → Feb 28 posting: period Jan 29 – Feb 27
85+
assertPeriod(periods.get(0), LocalDate.of(2025, 1, 29), LocalDate.of(2025, 2, 27));
86+
// Feb 28 → Mar 29 posting: period Feb 28 – Mar 28
87+
assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2025, 3, 28));
88+
// Mar 29 → Apr 29 posting: period Mar 29 – Apr 28
89+
assertPeriod(periods.get(2), LocalDate.of(2025, 3, 29), LocalDate.of(2025, 4, 28));
90+
// Apr 29 → May 29 posting: period Apr 29 – May 28 (extends past upToDate Apr 30)
91+
assertPeriod(periods.get(3), LocalDate.of(2025, 4, 29), LocalDate.of(2025, 5, 28));
92+
}
93+
94+
@Test
95+
void anniversaryMonthly_accountOpenedOn31st_shortMonthsUseLastDay() {
96+
// Jan 31 → Feb 28 (28 days), then Mar 31, Apr 30, May 31, Jun 30, …
97+
LocalDate start = LocalDate.of(2025, 1, 31);
98+
LocalDate end = LocalDate.of(2025, 5, 31);
99+
100+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
101+
SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
102+
103+
assertThat(periods).hasSize(5);
104+
assertPeriod(periods.get(0), LocalDate.of(2025, 1, 31), LocalDate.of(2025, 2, 27)); // Feb 28 - 1
105+
assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2025, 3, 30)); // Mar 31 - 1
106+
assertPeriod(periods.get(2), LocalDate.of(2025, 3, 31), LocalDate.of(2025, 4, 29)); // Apr 30 - 1
107+
assertPeriod(periods.get(3), LocalDate.of(2025, 4, 30), LocalDate.of(2025, 5, 30)); // May 31 - 1
108+
// last period extends past upToDate May 31 → May 31 – Jun 29
109+
assertPeriod(periods.get(4), LocalDate.of(2025, 5, 31), LocalDate.of(2025, 6, 29)); // Jun 30 - 1
110+
}
111+
112+
// ── ANNIVERSARY_QUARTERLY ────────────────────────────────────────────────
113+
114+
@Test
115+
void anniversaryQuarterly_accountOpenedOn15th_periodsAlignToThe15th() {
116+
// Account opened Jan 15 → posts every 3 months on the 15th
117+
LocalDate start = LocalDate.of(2024, 1, 15);
118+
LocalDate end = LocalDate.of(2024, 12, 31);
119+
120+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
121+
SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
122+
123+
assertThat(periods).hasSize(4);
124+
assertPeriod(periods.get(0), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 4, 14));
125+
assertPeriod(periods.get(1), LocalDate.of(2024, 4, 15), LocalDate.of(2024, 7, 14));
126+
assertPeriod(periods.get(2), LocalDate.of(2024, 7, 15), LocalDate.of(2024, 10, 14));
127+
// last period extends past Dec 31 → Oct 15 – Jan 14 2025
128+
assertPeriod(periods.get(3), LocalDate.of(2024, 10, 15), LocalDate.of(2025, 1, 14));
129+
}
130+
131+
@Test
132+
void anniversaryQuarterly_accountOpenedOn29th_februaryQuarterUsesLastDay() {
133+
// Nov 29 + 3 months = Feb 28 (Feb 2025 has 28 days)
134+
LocalDate start = LocalDate.of(2024, 11, 29);
135+
LocalDate end = LocalDate.of(2025, 5, 31);
136+
137+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
138+
SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
139+
140+
assertThat(periods).hasSize(3);
141+
// Nov 29 → Feb 28 posting: period Nov 29 – Feb 27
142+
assertPeriod(periods.get(0), LocalDate.of(2024, 11, 29), LocalDate.of(2025, 2, 27));
143+
// Feb 28 → May 29 posting: period Feb 28 – May 28
144+
assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2025, 5, 28));
145+
// May 29 → Aug 29 posting: period May 29 – Aug 28 (extends past upToDate May 31)
146+
assertPeriod(periods.get(2), LocalDate.of(2025, 5, 29), LocalDate.of(2025, 8, 28));
147+
}
148+
149+
// ── ANNIVERSARY_BIANNUAL ─────────────────────────────────────────────────
150+
151+
@Test
152+
void anniversaryBiAnnual_accountOpenedOn15th_periodsAlignToThe15th() {
153+
// Account opened Jan 15 → posts every 6 months on the 15th
154+
LocalDate start = LocalDate.of(2024, 1, 15);
155+
LocalDate end = LocalDate.of(2025, 1, 31);
156+
157+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
158+
SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
159+
160+
assertThat(periods).hasSize(3);
161+
assertPeriod(periods.get(0), LocalDate.of(2024, 1, 15), LocalDate.of(2024, 7, 14));
162+
assertPeriod(periods.get(1), LocalDate.of(2024, 7, 15), LocalDate.of(2025, 1, 14));
163+
// last period extends past Jan 31 → Jan 15 2025 – Jul 14 2025
164+
assertPeriod(periods.get(2), LocalDate.of(2025, 1, 15), LocalDate.of(2025, 7, 14));
165+
}
166+
167+
// ── ANNIVERSARY_ANNUAL ───────────────────────────────────────────────────
168+
169+
@Test
170+
void anniversaryAnnual_accountOpenedOn15th_periodsAlignToThe15th() {
171+
// Account opened Mar 15 → posts annually on Mar 15
172+
LocalDate start = LocalDate.of(2024, 3, 15);
173+
LocalDate end = LocalDate.of(2026, 3, 31);
174+
175+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
176+
SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
177+
178+
assertThat(periods).hasSize(3);
179+
assertPeriod(periods.get(0), LocalDate.of(2024, 3, 15), LocalDate.of(2025, 3, 14));
180+
assertPeriod(periods.get(1), LocalDate.of(2025, 3, 15), LocalDate.of(2026, 3, 14));
181+
// last period extends past Mar 31 2026 → Mar 15 2026 – Mar 14 2027
182+
assertPeriod(periods.get(2), LocalDate.of(2026, 3, 15), LocalDate.of(2027, 3, 14));
183+
}
184+
185+
@Test
186+
void anniversaryAnnual_accountOpenedOnFeb29_leapYearHandling() {
187+
// Feb 29 2024 (leap) → next year Feb has 28 days → post on Feb 28
188+
LocalDate start = LocalDate.of(2024, 2, 29);
189+
LocalDate end = LocalDate.of(2026, 3, 31);
190+
191+
List<LocalDateInterval> periods = savingsHelper.determineInterestPostingPeriods(start, end,
192+
SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL, FINANCIAL_YEAR_BEGINNING_MONTH, Collections.emptyList());
193+
194+
assertThat(periods).hasSize(3);
195+
// Feb 29 2024 → Feb 28 2025 posting: period Feb 29 2024 – Feb 27 2025
196+
assertPeriod(periods.get(0), LocalDate.of(2024, 2, 29), LocalDate.of(2025, 2, 27));
197+
// Feb 28 2025 → Feb 28 2026 posting: period Feb 28 2025 – Feb 27 2026
198+
assertPeriod(periods.get(1), LocalDate.of(2025, 2, 28), LocalDate.of(2026, 2, 27));
199+
// last period extends past Mar 31 2026 → Feb 28 2026 – Feb 27 2027
200+
assertPeriod(periods.get(2), LocalDate.of(2026, 2, 28), LocalDate.of(2027, 2, 27));
201+
}
202+
}

fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsDropdownReadPlatformServiceImpl.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,17 @@ public Collection<EnumOptionData> retrieveCompoundingInterestPeriodTypeOptions()
6565
}
6666

6767
@Override
68-
public Collection<EnumOptionData> retrieveInterestPostingPeriodTypeOptions() {
69-
final List<EnumOptionData> allowedOptions = Arrays.asList(
70-
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.DAILY), //
68+
public List<EnumOptionData> retrieveInterestPostingPeriodTypeOptions() {
69+
return Arrays.asList(SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.DAILY), //
7170
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.MONTHLY), //
7271
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.QUATERLY), //
7372
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.BIANNUAL), //
74-
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNUAL) //
73+
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNUAL), //
74+
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_MONTHLY), //
75+
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_QUARTERLY), //
76+
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_BIANNUAL), //
77+
SavingsEnumerations.interestPostingPeriodType(SavingsPostingInterestPeriodType.ANNIVERSARY_ANNUAL) //
7578
);
76-
77-
return allowedOptions;
7879
}
7980

8081
@Override

0 commit comments

Comments
 (0)