From 569fcdac21a364b8a4b4561957333b5f64d4bf8e Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Sat, 12 Sep 2026 12:50:21 -0500 Subject: [PATCH] FINERACT-2455: Working Capital - No backdated breach pause --- .../WorkingCapitalBreachPause.feature | 28 +++ ...italLoanBreachActionParseAndValidator.java | 22 ++ ...italLoanBreachPauseResetValidatorTest.java | 201 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachPauseResetValidatorTest.java diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature index 9ed88803b9e..a02c03a3a0f 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -954,3 +954,31 @@ Feature: Working Capital Breach Pause | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | | 1 | 2026-01-01 | 2026-01-09 | 9 | 90 | 90 | null | null | Then Admin closes the Working Capital loan with a full repayment on "04 January 2026" + + Scenario: Verify working capital loan breach pause - backdated pause before an active breach reset is rejected + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | delinquencyGraceDays | + | 6 | DAYS | PERCENTAGE | 50 | | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 800 | 10000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "800" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "800" EUR transaction amount + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "08 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "12 January 2026" + And Admin creates WC breach reset action with restart period from reset date + # A pause backdated behind the reset would re-date the periods the reset already settled + Then Initiating a Working Capital loan breach pause with startDate "09 January 2026" and endDate "10 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Breach pause cannot start before the latest breach reset date: 2026-01-12 | + # A pause starting on the reset date itself is still allowed + And Admin initiate a Working Capital loan breach pause with startDate "12 January 2026" and endDate "13 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | RESET | 2026-01-12 | | + | PAUSE | 2026-01-12 | 2026-01-13 | + Then Admin closes the Working Capital loan with a full repayment on "12 January 2026" diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java index 914273d66bd..182430ba4f8 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachActionParseAndValidator.java @@ -35,6 +35,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -130,6 +131,7 @@ private WorkingCapitalLoanBreachAction parseAndValidatePause(final JsonElement j validateStartBeforeEnd(dataValidator, startDate, endDate); validateNotBeforeScheduleStart(dataValidator, startDate, workingCapitalLoan); + validateNotBeforeBreachReset(dataValidator, startDate, existing); validateNoOverlap(dataValidator, startDate, endDate, existing); throwExceptionIfValidationWarningsExist(dataValidator); @@ -401,6 +403,26 @@ private int getBreachGraceDays(final WorkingCapitalLoan workingCapitalLoan) { return (details == null || details.getBreachGraceDays() == null) ? 0 : details.getBreachGraceDays(); } + /** + * A reset closes the breach period it lands on and restarts the evaluation from its own date. A pause that starts + * before that date would re-date periods the reset already settled, so backdating a pause behind the latest active + * reset is rejected. Resets that were undone are not considered: their split is restored by the undo, so there is + * nothing left to protect. + */ + private void validateNotBeforeBreachReset(final DataValidatorBuilder dataValidator, final LocalDate startDate, + final List existing) { + if (startDate == null) { + return; + } + activeBreachResetResolver.activeResets(existing).stream() // + .map(WorkingCapitalLoanBreachAction::getStartDate) // + .filter(Objects::nonNull) // + .max(LocalDate::compareTo) // + .filter(startDate::isBefore) // + .ifPresent(latestResetDate -> failParameterValidation(dataValidator, START_DATE, "must.not.be.before.breach.reset.date", + "Breach pause cannot start before the latest breach reset date: " + latestResetDate)); + } + private void validateNoOverlap(final DataValidatorBuilder dataValidator, final LocalDate startDate, final LocalDate endDate, final List existing) { if (startDate == null || endDate == null) { diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachPauseResetValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachPauseResetValidatorTest.java new file mode 100644 index 00000000000..a34f85f8860 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/validator/WorkingCapitalLoanBreachPauseResetValidatorTest.java @@ -0,0 +1,201 @@ +/** + * 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.portfolio.workingcapitalloan.validator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.google.gson.JsonElement; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.domain.ActionContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanActiveBreachResetResolver; +import org.apache.fineract.portfolio.workingcapitalloanbreach.domain.WorkingCapitalBreach; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * A breach pause must never be backdated behind a reset: the reset settles the period it lands on and restarts the + * evaluation from its own date, while a pause re-dates the periods it precedes. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WorkingCapitalLoanBreachPauseResetValidatorTest { + + private static final Long LOAN_ID = 1L; + + @Mock + private WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; + @Mock + private WorkingCapitalLoanBreachActionRepository breachActionRepository; + @Mock + private WorkingCapitalLoan loan; + @Mock + private WorkingCapitalLoanProductRelatedDetails productRelatedDetails; + @Mock + private WorkingCapitalBreach breachConfiguration; + + private WorkingCapitalLoanBreachActionParseAndValidator validator; + private LocalDate today; + private LocalDate scheduleStart; + + @BeforeEach + void setUp() { + validator = new WorkingCapitalLoanBreachActionParseAndValidator(new FromJsonHelper(), breachScheduleRepository, + new WorkingCapitalLoanActiveBreachResetResolver(breachActionRepository), breachActionRepository); + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + today = LocalDate.now(ZoneId.systemDefault()); + scheduleStart = today.minusDays(60); + final HashMap businessDates = new HashMap<>(); + businessDates.put(BusinessDateType.BUSINESS_DATE, today); + businessDates.put(BusinessDateType.COB_DATE, today.minusDays(1)); + ThreadLocalContextUtil.setBusinessDates(businessDates); + + when(loan.getId()).thenReturn(LOAN_ID); + when(loan.isOpen()).thenReturn(true); + when(loan.getLoanProductRelatedDetails()).thenReturn(productRelatedDetails); + when(productRelatedDetails.getBreach()).thenReturn(breachConfiguration); + when(breachActionRepository.isBreachDisabledAsOf(eq(LOAN_ID), any())).thenReturn(false); + + final WorkingCapitalLoanBreachSchedule firstPeriod = new WorkingCapitalLoanBreachSchedule(); + firstPeriod.setFromDate(scheduleStart); + when(breachScheduleRepository.findTopByLoanIdOrderByPeriodNumberAsc(LOAN_ID)).thenReturn(Optional.of(firstPeriod)); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + void pauseStartingBeforeTheResetIsRejected() { + final LocalDate resetDate = today.minusDays(10); + + assertThatThrownBy(() -> validate(resetDate.minusDays(1), resetDate.plusDays(2), List.of(reset(resetDate)))) + .isInstanceOf(PlatformApiDataValidationException.class).hasMessageContaining("Validation errors exist"); + } + + @Test + void pauseStartingOnTheResetDateIsAccepted() { + final LocalDate resetDate = today.minusDays(10); + + final WorkingCapitalLoanBreachAction pause = validate(resetDate, resetDate.plusDays(2), List.of(reset(resetDate))); + + assertThat(pause.getAction()).isEqualTo(WorkingCapitalLoanBreachActionType.PAUSE); + assertThat(pause.getStartDate()).isEqualTo(resetDate); + } + + @Test + void pauseStartingAfterTheResetIsAccepted() { + final LocalDate resetDate = today.minusDays(10); + + assertThatCode(() -> validate(resetDate.plusDays(1), resetDate.plusDays(3), List.of(reset(resetDate)))).doesNotThrowAnyException(); + } + + @Test + void backdatedPauseIsAcceptedWhenThereIsNoReset() { + assertThatCode(() -> validate(today.minusDays(20), today.minusDays(18), List.of())).doesNotThrowAnyException(); + } + + @Test + void theLatestActiveResetIsTheBoundary() { + final LocalDate firstReset = today.minusDays(20); + final LocalDate latestReset = today.minusDays(5); + + assertThatThrownBy(() -> validate(firstReset.plusDays(1), latestReset.plusDays(2), List.of(reset(firstReset), reset(latestReset)))) + .isInstanceOf(PlatformApiDataValidationException.class); + } + + @Test + void anUndoneResetNoLongerBlocksABackdatedPause() { + final LocalDate resetDate = today.minusDays(10); + + assertThatCode( + () -> validate(resetDate.minusDays(1), resetDate.plusDays(2), List.of(reset(resetDate), undoReset(today.minusDays(3))))) + .doesNotThrowAnyException(); + } + + @Test + void theResetUnderTheUndoneOneStillBlocksABackdatedPause() { + final LocalDate firstReset = today.minusDays(20); + final LocalDate undoneReset = today.minusDays(5); + + assertThatThrownBy(() -> validate(firstReset.minusDays(1), firstReset.plusDays(2), + List.of(reset(firstReset), reset(undoneReset), undoReset(today.minusDays(2))))) + .isInstanceOf(PlatformApiDataValidationException.class); + } + + private WorkingCapitalLoanBreachAction validate(final LocalDate startDate, final LocalDate endDate, + final List existing) { + return validator.validateAndParse(command(startDate, endDate), loan, existing); + } + + private WorkingCapitalLoanBreachAction reset(final LocalDate resetDate) { + return action(WorkingCapitalLoanBreachActionType.RESET, resetDate); + } + + private WorkingCapitalLoanBreachAction undoReset(final LocalDate undoDate) { + return action(WorkingCapitalLoanBreachActionType.UNDO_RESET, undoDate); + } + + private WorkingCapitalLoanBreachAction action(final WorkingCapitalLoanBreachActionType type, final LocalDate startDate) { + final WorkingCapitalLoanBreachAction action = new WorkingCapitalLoanBreachAction(); + action.setAction(type); + action.setStartDate(startDate); + return action; + } + + private JsonCommand command(final LocalDate startDate, final LocalDate endDate) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + final String json = "{\"action\":\"pause\",\"dateFormat\":\"yyyy-MM-dd\",\"locale\":\"en\",\"startDate\":\"" + + startDate.format(formatter) + "\",\"endDate\":\"" + endDate.format(formatter) + "\"}"; + final FromJsonHelper jsonHelper = new FromJsonHelper(); + final JsonElement parsed = jsonHelper.parse(json); + return JsonCommand.from(json, parsed, jsonHelper, null, null, null, null, null, LOAN_ID, null, null, null, null, null, null, null, + null); + } +}