diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java
index 47b08147828..472cccc12fc 100644
--- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java
+++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java
@@ -23,7 +23,9 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
+import java.time.MonthDay;
import java.time.OffsetDateTime;
+import java.time.YearMonth;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@@ -474,4 +476,29 @@ public static LocalDateTime convertDateTimeStringToLocalDateTime(String dateTime
public static LocalDate min(@NonNull LocalDate date1, @NonNull LocalDate date2) {
return date1.isBefore(date2) ? date1 : date2;
}
+
+ /**
+ * Builds a {@link MonthDay} from month and day, clamping the day to the last valid day of the month for the current
+ * business year if necessary. Use when reading (month, day) from storage (e.g. fee_on_month, fee_on_day) where the
+ * combination may be invalid (e.g. day 30 for February).
+ *
+ * The year is derived from {@link #getBusinessLocalDate()}. This makes February sensitive to leap years:
+ *
+ * - In a leap year, February allows 29 (Feb 30/31 are clamped to 29).
+ * - In a non-leap year, February is clamped to 28 (Feb 29/30/31 are clamped to 28).
+ *
+ *
+ * @param month
+ * month 1–12
+ * @param day
+ * day of month (may exceed month length; will be clamped)
+ * @return valid MonthDay (day clamped to month length for the current business year)
+ */
+ public static MonthDay safeMonthDay(int month, int day) {
+ LocalDate businessDate = getBusinessLocalDate();
+ int year = businessDate.getYear();
+ int maxDay = YearMonth.of(year, month).lengthOfMonth();
+ int safeDay = Math.min(day, maxDay);
+ return MonthDay.of(month, safeDay);
+ }
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java
index 4f91636c684..9a947c029ca 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java
@@ -439,9 +439,8 @@ public StandingInstructionData mapRow(final ResultSet rs, @SuppressWarnings("unu
MonthDay recurrenceOnMonthDay = null;
final Integer recurrenceOnDay = JdbcSupport.getInteger(rs, "recurrenceOnDay");
final Integer recurrenceOnMonth = JdbcSupport.getInteger(rs, "recurrenceOnMonth");
- if (recurrenceOnDay != null) {
- recurrenceOnMonthDay = MonthDay.now(DateUtils.getDateTimeZoneOfTenant()).withMonth(recurrenceOnMonth)
- .withDayOfMonth(recurrenceOnDay);
+ if (recurrenceOnDay != null && recurrenceOnMonth != null) {
+ recurrenceOnMonthDay = DateUtils.safeMonthDay(recurrenceOnMonth, recurrenceOnDay);
}
final Integer transferType = rs.getInt("transferType");
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java
index 98562a56dad..a5ef2101f01 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java
@@ -339,7 +339,7 @@ public ChargeData mapRow(final ResultSet rs, @SuppressWarnings("unused") final i
final Integer feeOnMonth = JdbcSupport.getInteger(rs, "feeOnMonth");
final Integer feeOnDay = JdbcSupport.getInteger(rs, "feeOnDay");
if (feeOnDay != null && feeOnMonth != null) {
- feeOnMonthDay = MonthDay.now(DateUtils.getDateTimeZoneOfTenant()).withDayOfMonth(feeOnDay).withMonth(feeOnMonth);
+ feeOnMonthDay = DateUtils.safeMonthDay(feeOnMonth, feeOnDay);
}
final BigDecimal minCap = rs.getBigDecimal("minCap");
final BigDecimal maxCap = rs.getBigDecimal("maxCap");
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountChargeReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountChargeReadPlatformServiceImpl.java
index b28b2ec9218..7690463790c 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountChargeReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountChargeReadPlatformServiceImpl.java
@@ -119,7 +119,7 @@ public SavingsAccountChargeData mapRow(final ResultSet rs, @SuppressWarnings("un
final Integer feeOnMonth = JdbcSupport.getInteger(rs, "feeOnMonth");
final Integer feeOnDay = JdbcSupport.getInteger(rs, "feeOnDay");
if (feeOnDay != null && feeOnMonth != null) {
- feeOnMonthDay = MonthDay.now(DateUtils.getDateTimeZoneOfTenant()).withMonth(feeOnMonth).withDayOfMonth(feeOnDay);
+ feeOnMonthDay = DateUtils.safeMonthDay(feeOnMonth, feeOnDay);
}
final int chargeCalculation = rs.getInt("chargeCalculation");
diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/service/DateUtilsTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/service/DateUtilsTest.java
index ee7ef37f2f9..0a91ad109b4 100644
--- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/service/DateUtilsTest.java
+++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/service/DateUtilsTest.java
@@ -24,6 +24,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.time.MonthDay;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
@@ -91,4 +92,69 @@ public void isDateInTheFuture() {
public void getBusinesLocalDate() {
assertTrue(DateUtils.isEqualBusinessDate(LocalDate.of(2022, 6, 12)));
}
+
+ // --- safeMonthDay (clamps day to last valid day of month for current business year) ---
+
+ @Test
+ public void safeMonthDay_validDay_returnsSameMonthDay() {
+ assertEquals(MonthDay.of(1, 15), DateUtils.safeMonthDay(1, 15));
+ assertEquals(MonthDay.of(3, 31), DateUtils.safeMonthDay(3, 31));
+ assertEquals(MonthDay.of(4, 30), DateUtils.safeMonthDay(4, 30));
+ assertEquals(MonthDay.of(12, 31), DateUtils.safeMonthDay(12, 31));
+ }
+
+ @Test
+ public void safeMonthDay_february29_inNonLeapYear_clampedTo28() {
+ // business date initialized to 2022-06-12 (non-leap year) in @BeforeEach
+ assertEquals(MonthDay.of(2, 28), DateUtils.safeMonthDay(2, 29));
+ }
+
+ @Test
+ public void safeMonthDay_february30_inNonLeapYear_clampedTo28() {
+ assertEquals(MonthDay.of(2, 28), DateUtils.safeMonthDay(2, 30));
+ }
+
+ @Test
+ public void safeMonthDay_february31_inNonLeapYear_clampedTo28() {
+ assertEquals(MonthDay.of(2, 28), DateUtils.safeMonthDay(2, 31));
+ }
+
+ @Test
+ public void safeMonthDay_february29_inLeapYear_preserved() {
+ ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.of(2024, 6, 12))));
+ assertEquals(MonthDay.of(2, 29), DateUtils.safeMonthDay(2, 29));
+ }
+
+ @Test
+ public void safeMonthDay_february30_inLeapYear_clampedTo29() {
+ ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.of(2024, 6, 12))));
+ assertEquals(MonthDay.of(2, 29), DateUtils.safeMonthDay(2, 30));
+ }
+
+ @Test
+ public void safeMonthDay_february31_inLeapYear_clampedTo29() {
+ ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.of(2024, 6, 12))));
+ assertEquals(MonthDay.of(2, 29), DateUtils.safeMonthDay(2, 31));
+ }
+
+ @Test
+ public void safeMonthDay_april31_clampedTo30() {
+ assertEquals(MonthDay.of(4, 30), DateUtils.safeMonthDay(4, 31));
+ }
+
+ @Test
+ public void safeMonthDay_june31_clampedTo30() {
+ assertEquals(MonthDay.of(6, 30), DateUtils.safeMonthDay(6, 31));
+ }
+
+ @Test
+ public void safeMonthDay_november31_clampedTo30() {
+ assertEquals(MonthDay.of(11, 30), DateUtils.safeMonthDay(11, 31));
+ }
+
+ @Test
+ public void safeMonthDay_firstDayOfMonth_preserved() {
+ assertEquals(MonthDay.of(2, 1), DateUtils.safeMonthDay(2, 1));
+ assertEquals(MonthDay.of(7, 1), DateUtils.safeMonthDay(7, 1));
+ }
}