From 4836c5558e60a4022df34bc35a22c34726e398f6 Mon Sep 17 00:00:00 2001 From: oluexpert99 Date: Tue, 17 Mar 2026 15:46:55 +0100 Subject: [PATCH] FINERACT-2512: Fix DateTimeException for invalid day/month combinations in savings product templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build MonthDay from DB values only (feeOnMonth, feeOnDay) — no dependency on current date. - Add DateUtils.safeMonthDay(month, day) to clamp day to month length using business date year. - ApplsafeMonthDay in ChargeReadPlatformServiceImpl, SavingsAccountChargeReadPlatformServiceImpl and StandingInstructionReadPlatformServiceImpl. - Add DateUtilsTest unit tests for safeMonthDay covering valid, leap-year and clamped cases. Signed-off-by: oluexpert99 --- .../core/service/DateUtils.java | 27 ++++++++ ...ingInstructionReadPlatformServiceImpl.java | 5 +- .../ChargeReadPlatformServiceImpl.java | 2 +- ...sAccountChargeReadPlatformServiceImpl.java | 2 +- .../core/service/DateUtilsTest.java | 66 +++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) 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: + *

+ * + * @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)); + } }