Cocoa-Puffs commented on code in PR #6588: URL: https://github.com/apache/fineract/pull/6588#discussion_r4312095651
########## integration-tests/src/test/java/org/apache/fineract/integrationtests/MonthEndDueDateStrategyTest.java: ########## @@ -0,0 +1,931 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod; +import org.apache.fineract.client.models.PostHolidaysRequest; +import org.apache.fineract.client.models.PostHolidaysRequestOffices; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.client.models.PutLoanProductsProductIdRequest; +import org.apache.fineract.client.models.PutLoansLoanIdRequest; +import org.apache.fineract.client.models.StringEnumOptionData; +import org.apache.fineract.client.models.WorkingDaysData; +import org.apache.fineract.client.models.WorkingDaysUpdateRequest; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignGroupHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignRawHttpHelper; +import org.apache.fineract.integrationtests.client.feign.modules.LoanRequestBuilders; +import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData; +import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.DaysInMonthType; +import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.DaysInYearType; +import org.apache.fineract.integrationtests.common.CalendarHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.HolidayHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.WorkingDaysHelper; +import org.junit.jupiter.api.Test; + +/** + * A monthly due date whose anchor day (the disbursement's day of month) is missing from the target month - 30 February, + * 31 April - is clamped back to the last day of the month by default, or rolled forward to the first day of the next + * month when the product says so. Either way the anchor day comes back as soon as a month has it. + */ +public class MonthEndDueDateStrategyTest extends FeignLoanTestBase { + + private static final String LAST_DAY_OF_MONTH = "LAST_DAY_OF_MONTH"; + private static final String FIRST_DAY_OF_NEXT_MONTH = "FIRST_DAY_OF_NEXT_MONTH"; + + private static final Integer DISBURSEMENT_DATE = 1; + private static final Integer SUBMITTED_ON_DATE = 2; + private static final Integer MOVE_TO_NEXT_WORKING_DAY = 2; + private static final Integer RESCHEDULE_TO_NEXT_REPAYMENT_DATE = 1; + private static final Integer RESCHEDULE_TO_SPECIFIED_DATE = 2; + + private static final double PRINCIPAL = 10000.0; + private static final double INTEREST_RATE = 12.0; + + private final FeignGroupHelper groupHelper = new FeignGroupHelper(FineractFeignClientHelper.getFineractFeignClient()); + + @Test + public void testProductExposesAndUpdatesTheStrategy() { + final Long unsetProductId = createLoanProduct(create4IProgressive()); + assertNull(retrieveLoanProduct(unsetProductId).getMonthEndDueDateStrategy(), "An unset strategy reads back as unset"); + + for (final PostLoanProductsRequest template : List.of(create4IProgressive(), create4ICumulative())) { + final Long productId = createLoanProduct( + template.monthEndDueDateStrategy(PostLoanProductsRequest.MonthEndDueDateStrategyEnum.FIRST_DAY_OF_NEXT_MONTH)); + assertEquals(FIRST_DAY_OF_NEXT_MONTH, strategyOf(retrieveLoanProduct(productId)), template.getLoanScheduleType()); + + updateLoanProduct(productId, new PutLoanProductsProductIdRequest() + .monthEndDueDateStrategy(PutLoanProductsProductIdRequest.MonthEndDueDateStrategyEnum.LAST_DAY_OF_MONTH)); + assertEquals(LAST_DAY_OF_MONTH, strategyOf(retrieveLoanProduct(productId)), template.getLoanScheduleType()); + } + + final List<StringEnumOptionData> options = getLoanProductTemplate(false).getMonthEndDueDateStrategyOptions(); + assertNotNull(options, "The template offers the strategies"); + assertEquals(List.of(LAST_DAY_OF_MONTH, FIRST_DAY_OF_NEXT_MONTH), options.stream().map(StringEnumOptionData::getId).toList()); + } + + @Test + public void testProductStrategyCanBeClearedAndRejectsUnknownValues() { + final Long productId = createLoanProduct( + create4IProgressive().monthEndDueDateStrategy(PostLoanProductsRequest.MonthEndDueDateStrategyEnum.FIRST_DAY_OF_NEXT_MONTH)); + + final RuntimeException exception = assertThrows(RuntimeException.class, + () -> FeignRawHttpHelper.put("/loanproducts/" + productId, """ + {"monthEndDueDateStrategy": "SOMEWHERE_IN_BETWEEN", "locale": "en"} + """)); + assertTrue(exception.getMessage().startsWith("HTTP 400"), exception.getMessage()); + assertEquals(FIRST_DAY_OF_NEXT_MONTH, strategyOf(retrieveLoanProduct(productId)), "A rejected update changes nothing"); + + FeignRawHttpHelper.put("/loanproducts/" + productId, """ + {"monthEndDueDateStrategy": null, "locale": "en"} + """); + assertNull(retrieveLoanProduct(productId).getMonthEndDueDateStrategy(), "Sending null clears the strategy"); + } + + @Test + public void testLoanInheritsTheProductStrategy() { + final Long clientId = createClient(); + runAt("31 January 2025", () -> { + final Long progressiveLoanId = progressiveLoan(clientId, product(create4IProgressive(), FIRST_DAY_OF_NEXT_MONTH), + "31 January 2025", 3); + final Long cumulativeLoanId = cumulativeLoan(clientId, product(create4ICumulative(), FIRST_DAY_OF_NEXT_MONTH), + "31 January 2025", 3); + final Long unsetLoanId = progressiveLoan(clientId, product(create4IProgressive(), null), "31 January 2025", 3); + + assertEquals(FIRST_DAY_OF_NEXT_MONTH, getLoanDetails(progressiveLoanId).getMonthEndDueDateStrategy().getId()); + assertEquals(FIRST_DAY_OF_NEXT_MONTH, getLoanDetails(cumulativeLoanId).getMonthEndDueDateStrategy().getId()); + assertNull(getLoanDetails(unsetLoanId).getMonthEndDueDateStrategy()); + }); + } + + /** + * The ticket's worked example: disbursed 31 January 2025, three monthly periods, Actual/Actual. February has no + * 31st and neither has April; March does. Interest is 12% a year on the declining balance over the actual days of + * each period - 29, 30 and 31 days when rolled against 28, 31 and 30 when clamped - so the rolled schedule bills + * 95.34 = 10,000.00 * 12% * 29/365 in its first period against 92.05 = 10,000.00 * 12% * 28/365, and pays a + * slightly higher EMI because its money is out for a day longer before the first repayment. + */ + @Test + public void testWorkedExampleOnProgressiveLoan() { + final Long clientId = createClient(); + runAt("31 January 2025", () -> { + final ScheduleType progressive = progressive(MonthEndDueDateStrategyTest::actualActual); + verifyRepaymentSchedule(disbursedLoan(clientId, progressive, LAST_DAY_OF_MONTH, "31 January 2025", 3), // + installment(PRINCIPAL, null, "31 January 2025"), // + installment(3305.77, 92.05, 3397.82, false, "28 February 2025"), // + installment(3329.59, 68.23, 3397.82, false, "31 March 2025"), // + installment(3364.64, 33.19, 3397.83, false, "30 April 2025")); + verifyRepaymentSchedule(disbursedLoan(clientId, progressive, FIRST_DAY_OF_NEXT_MONTH, "31 January 2025", 3), // + installment(PRINCIPAL, null, "31 January 2025"), // + installment(3303.22, 95.34, 3398.56, false, "01 March 2025"), // + installment(3332.51, 66.05, 3398.56, false, "31 March 2025"), // + installment(3364.27, 34.29, 3398.56, false, "01 May 2025")); + }); + } + + /** + * The worked example on a cumulative product with no meeting calendar lands on the same dates. The cumulative EMI + * is worked out from the nominal monthly rate, so it does not move; the interest split does, following the actual + * days of each period, and the last instalment absorbs the difference. + */ + @Test + public void testWorkedExampleOnCumulativeLoan() { + withInterestChargedFromDisbursementDate(() -> { + final Long clientId = createClient(); + runAt("31 January 2025", () -> { + final ScheduleType cumulative = cumulative(MonthEndDueDateStrategyTest::actualActual); + verifyRepaymentSchedule(disbursedLoan(clientId, cumulative, LAST_DAY_OF_MONTH, "31 January 2025", 3), // + installment(PRINCIPAL, null, "31 January 2025"), // + installment(3307.25, 92.05, 3399.30, false, "28 February 2025"), // + installment(3331.09, 68.21, 3399.30, false, "31 March 2025"), // + installment(3361.66, 33.16, 3394.82, false, "30 April 2025")); + verifyRepaymentSchedule(disbursedLoan(clientId, cumulative, FIRST_DAY_OF_NEXT_MONTH, "31 January 2025", 3), // + installment(PRINCIPAL, null, "31 January 2025"), // + installment(3303.96, 95.34, 3399.30, false, "01 March 2025"), // + installment(3333.26, 66.04, 3399.30, false, "31 March 2025"), // + installment(3362.78, 34.27, 3397.05, false, "01 May 2025")); + }); + }); + } + + /** + * Under Actual/Actual interest follows the actual days. Disbursed 30 January 2025: the rolled first period (30 + * January - 1 March, 30 days) is a day longer than the clamped one (30 January - 28 February, 29 days), and the + * second (1 March - 30 March, 29 days) a day shorter than its clamped counterpart (28 February - 30 March, 30 + * days). + */ + @Test + public void testRolledDueDateMovesInterestBetweenPeriodsUnderActualActual() { + withInterestChargedFromDisbursementDate(() -> { + final Long clientId = createClient(); + runAt("30 January 2025", () -> { + for (final ScheduleType type : scheduleTypes(MonthEndDueDateStrategyTest::actualActual)) { + final Long clampedLoanId = disbursedLoan(clientId, type, LAST_DAY_OF_MONTH, "30 January 2025", 3); + final Long rolledLoanId = disbursedLoan(clientId, type, FIRST_DAY_OF_NEXT_MONTH, "30 January 2025", 3); + + assertEquals(List.of(LocalDate.of(2025, 2, 28), LocalDate.of(2025, 3, 30), LocalDate.of(2025, 4, 30)), + dueDates(clampedLoanId), type.name()); + assertEquals(List.of(LocalDate.of(2025, 3, 1), LocalDate.of(2025, 3, 30), LocalDate.of(2025, 4, 30)), + dueDates(rolledLoanId), type.name()); + + final List<BigDecimal> clamped = interests(clampedLoanId); + final List<BigDecimal> rolled = interests(rolledLoanId); + assertTrue(rolled.get(0).compareTo(clamped.get(0)) > 0, Review Comment: The test now checks the full schedule (principal, interest, total, date) for all four loans -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
