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]

Reply via email to