From 7155e1aaf3a16c3e48173fa047a6afd7087b0c2d Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Sat, 12 Sep 2026 12:50:21 -0500 Subject: [PATCH 1/3] FINERACT-2455: Working Capital - No backdated breach pause --- .../WorkingCapitalBreachPause.feature | 40 ++++ ...gCapitalLoanBreachScheduleServiceImpl.java | 36 +++- ...italLoanBreachActionParseAndValidator.java | 22 ++ ...italLoanBreachResetFlagDerivationTest.java | 16 +- ...italLoanBreachPauseResetValidatorTest.java | 201 ++++++++++++++++++ ...WorkingCapitalLoanBreachResetUndoTest.java | 53 ++++- 6 files changed, 356 insertions(+), 12 deletions(-) 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..97e87744258 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,43 @@ 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 + # The reset closes period 2 on the day before the reset date and starts period 3 on it + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400 | 400 | null | true | + | 2 | 2026-01-07 | 2026-01-11 | 5 | 400 | 400 | null | true | + | 3 | 2026-01-12 | 2026-01-17 | 6 | 400 | 400 | null | null | + # 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 | + # The accepted pause must extend only the restarted period: the split and the settled breach flags stay + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400 | 400 | null | true | + | 2 | 2026-01-07 | 2026-01-11 | 5 | 400 | 400 | null | true | + | 3 | 2026-01-12 | 2026-01-19 | 8 | 400 | 400 | null | null | + 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/service/WorkingCapitalLoanBreachScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java index 18e1fb0db07..a81a372a636 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java @@ -22,7 +22,9 @@ import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -285,11 +287,12 @@ public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) { } final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); final List effectivePauses = findEffectivePauses(loan.getId()); + final List restartResetDates = activeRestartResetDates(loan.getId()); final LocalDate businessDate = DateUtils.getBusinessLocalDate(); LocalDate fromDate = periods.getFirst().getFromDate(); for (final WorkingCapitalLoanBreachSchedule period : periods) { period.setFromDate(fromDate); - period.setToDate(naturalToDate(loan, period.getPeriodNumber(), fromDate, params)); + period.setToDate(baseToDate(loan, period.getPeriodNumber(), fromDate, params, restartResetDates)); applyRecordedPauses(period, effectivePauses); recomputeBreach(period, businessDate); fromDate = period.getToDate().plusDays(1); @@ -476,6 +479,37 @@ private LocalDate naturalToDate(final WorkingCapitalLoan loan, final int periodN .plusDays(graceDays); } + /** + * End date of a period before the pauses are replayed on top of it: the natural end, closed one day early when a + * reset that restarted the schedule falls inside the period. + * + * A restart reset cuts its period short and starts a new one on the reset date, and that cut is not recoverable + * from the frequency alone. Rebuilding the geometry from the natural lengths only would stretch the cut period back + * to full length and swallow the period the reset started, which also moves the reset flag onto the merged period + * and un-evaluates a breach that was already settled. + */ + private LocalDate baseToDate(final WorkingCapitalLoan loan, final int periodNumber, final LocalDate fromDate, + final EffectiveBreachRescheduleParams params, final List restartResetDates) { + final LocalDate naturalToDate = naturalToDate(loan, periodNumber, fromDate, params); + return restartResetDates.stream() // + .filter(resetDate -> resetDate.isAfter(fromDate) && !resetDate.isAfter(naturalToDate)) // + .min(Comparator.naturalOrder()) // + .map(resetDate -> resetDate.minusDays(1)) // + .orElse(naturalToDate); + } + + /** + * Start dates of the active resets that restarted the schedule. A reset that only flags its period leaves the + * geometry untouched and is therefore not a cut. + */ + private List activeRestartResetDates(final Long loanId) { + return activeBreachResetResolver.activeResets(loanId).stream() // + .filter(reset -> Boolean.TRUE.equals(reset.getRestartPeriodFromResetDate())) // + .map(WorkingCapitalLoanBreachAction::getStartDate) // + .filter(Objects::nonNull) // + .toList(); + } + private Integer getBreachGraceDays(final WorkingCapitalLoan loan) { final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); return (details == null || details.getBreachGraceDays() == null) ? 0 : details.getBreachGraceDays(); 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/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java index 5c13c14c61a..f60843eff62 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java @@ -209,9 +209,13 @@ private String dump() { return sb.toString(); } - /** The pause recalculation still re-dates every period, so the flag must follow the reset date to its new row. */ + /** + * The pause recalculation rebuilds the geometry from the natural lengths, so it has to reproduce the cut the + * restart reset made. Otherwise period 2 stretches back to its full 60 days, swallows the restarted period and + * drags the reset flag with it. + */ @Test - void pauseAfterRestartReset_keepsTheFlagOnThePeriodHoldingTheResetDate() { + void pauseAfterRestartReset_keepsTheSplitAndExtendsOnlyTheRestartedPeriod() { givenBreachConfig(60); businessDate(LocalDate.of(2026, 7, 1)); final LocalDate resetDate = LocalDate.of(2026, 4, 15); @@ -225,11 +229,15 @@ void pauseAfterRestartReset_keepsTheFlagOnThePeriodHoldingTheResetDate() { final String state = dump(); final WorkingCapitalLoanBreachSchedule holdingReset = periodContaining(resetDate); assertAll(state, // - () -> assertEquals(2, holdingReset.getPeriodNumber(), "the re-dated period 2 holds the reset date"), + () -> assertEquals(3, holdingReset.getPeriodNumber(), "the restarted period 3 still holds the reset date"), () -> assertTrue(holdingReset.isReset(), "period holding the reset date is flagged"), () -> assertEquals(1, sorted().stream().filter(WorkingCapitalLoanBreachSchedule::isReset).count(), "one flag"), + () -> assertEquals(LocalDate.of(2026, 4, 14), sorted().get(1).getToDate(), + "the cut the reset made on period 2 survives the pause"), + () -> assertEquals(LocalDate.of(2026, 6, 23), sorted().get(2).getToDate(), + "only the restarted period absorbs the 10 paused days"), () -> assertEquals(0, BigDecimal.valueOf(100).compareTo(balance.getBreachPastDueAmount()), - "past due anchored on the flagged period 2, ended unpaid on 05-10")); + "past due anchored on the restarted period 3, ended unpaid on 06-23")); } /** 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); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java index dbd4556a450..d637cb5f963 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java @@ -37,6 +37,8 @@ public class FeignWorkingCapitalLoanBreachResetUndoTest extends FeignWorkingCapitalTestBase { private static final int BREACH_FREQUENCY = 60; + // Short frequency used by the tests that need several periods inside a couple of weeks. + private static final int SHORT_BREACH_FREQUENCY = 6; private static final String BREACH_FREQUENCY_TYPE = "DAYS"; private static final String BREACH_AMOUNT_CALCULATION_TYPE = "PERCENTAGE"; private static final BigDecimal BREACH_AMOUNT_PERCENT = BigDecimal.valueOf(50); @@ -401,8 +403,8 @@ private Long setupGraceDaysBreachLoanWithPause() { } @Test - @DisplayName("A pause recorded after a restart reset keeps the reset flag on the period holding the reset date") - void pauseAfterRestartReset_keepsTheFlagOnThePeriodHoldingTheResetDate() { + @DisplayName("A pause recorded after a restart reset keeps the split and extends only the restarted period") + void pauseAfterRestartReset_keepsTheSplitAndExtendsOnlyTheRestartedPeriod() { runAt("2026-01-01", () -> { final Long loanId = setupCommonBreachLoan(); @@ -413,19 +415,56 @@ void pauseAfterRestartReset_keepsTheFlagOnThePeriodHoldingTheResetDate() { advanceBusinessDateWithCob(loanId, "2026-04-15", "2026-04-20"); createBreachPause(loanId, "20 April 2026", "29 April 2026"); + // The reset closed period 2 on 14 Apr and started period 3 on the reset date. The pause must leave both + // boundaries alone and only push the end of the restarted period out by its 10 days. validateBreachSchedule(getBreachSchedule(loanId), // period(1, "2026-01-01", "2026-03-01", 60, "400.00", "100.00", true, false), // - period(2, "2026-03-02", "2026-05-10", 70, "400.00", "400.00", null, true), // - period(3, "2026-05-11", "2026-07-09", 60, "400.00", "400.00", null, false)); + period(2, "2026-03-02", "2026-04-14", 44, "400.00", "400.00", true, false), // + period(3, "2026-04-15", "2026-06-23", 70, "400.00", "400.00", null, true)); validateBreachPastDueAmount(getBreachPastDueAmount(loanId), "0"); advanceBusinessDateWithCob(loanId, "2026-04-20", "2026-06-01"); + // The pause bought time on the restarted period: it has not expired yet, so nothing new is past due and + // the two periods the reset had already settled keep their breach flags. validateBreachSchedule(getBreachSchedule(loanId), // period(1, "2026-01-01", "2026-03-01", 60, "400.00", "100.00", true, false), // - period(2, "2026-03-02", "2026-05-10", 70, "400.00", "400.00", true, true), // - period(3, "2026-05-11", "2026-07-09", 60, "400.00", "400.00", null, false)); - validateBreachPastDueAmount(getBreachPastDueAmount(loanId), "400"); + period(2, "2026-03-02", "2026-04-14", 44, "400.00", "400.00", true, false), // + period(3, "2026-04-15", "2026-06-23", 70, "400.00", "400.00", null, true)); + validateBreachPastDueAmount(getBreachPastDueAmount(loanId), "0"); + }); + } + + @Test + @DisplayName("A pause starting on the reset date itself extends the restarted period and leaves the earlier ones settled") + void pauseOnTheResetDate_extendsOnlyTheRestartedPeriod() { + runAt("2026-01-01", () -> { + final Long clientId = createClient("01 January 2026"); + final Long productId = createWcProductWithBreachConfig(SHORT_BREACH_FREQUENCY, BREACH_FREQUENCY_TYPE, + BREACH_AMOUNT_CALCULATION_TYPE, BREACH_AMOUNT_PERCENT, BREACH_GRACE_DAYS); + final Long loanId = createApproveAndDisburseWcLoan(clientId, productId, PRINCIPAL, "01 January 2026"); + runInlineWcCob(loanId); + + advanceBusinessDateWithCob(loanId, "2026-01-01", "2026-01-08"); + advanceBusinessDateWithCob(loanId, "2026-01-08", "2026-01-12"); + createBreachResetWithRestartPeriod(loanId); + + validateBreachSchedule(getBreachSchedule(loanId), // + period(1, "2026-01-01", "2026-01-06", 6, "400.00", "400.00", true, false), // + period(2, "2026-01-07", "2026-01-11", 5, "400.00", "400.00", true, false), // + period(3, "2026-01-12", "2026-01-17", 6, "400.00", "400.00", null, true)); + + createBreachPause(loanId, "12 January 2026", "13 January 2026"); + + // The pause shares its start date with the restarted period, so only the end date moves. + validateBreachSchedule(getBreachSchedule(loanId), // + period(1, "2026-01-01", "2026-01-06", 6, "400.00", "400.00", true, false), // + period(2, "2026-01-07", "2026-01-11", 5, "400.00", "400.00", true, false), // + period(3, "2026-01-12", "2026-01-19", 8, "400.00", "400.00", null, true)); + validateBreachPastDueAmount(getBreachPastDueAmount(loanId), "0"); + validateBreachActions(getBreachActions(loanId), // + action("RESET", "2026-01-12"), // + action("PAUSE", "2026-01-12")); }); } From e6932552f18d7188eedcbc606881e61a0c766ba7 Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Tue, 22 Sep 2026 16:26:00 +0100 Subject: [PATCH 2/3] FINERACT-2455: Consistent breach reprocessing --- .../WorkingCapitalBreachReschedule.feature | 42 ++++ ...ngCapitalLoanBreachScheduleRepository.java | 8 - ...gCapitalLoanActiveBreachResetResolver.java | 35 ++- ...pitalLoanBreachActionWriteServiceImpl.java | 7 +- ...rkingCapitalLoanBreachScheduleService.java | 8 +- ...gCapitalLoanBreachScheduleServiceImpl.java | 232 +++++++++++------- ...italLoanBreachActionParseAndValidator.java | 9 +- ...italLoanActiveBreachResetResolverTest.java | 32 ++- ...italLoanBreachResetFlagDerivationTest.java | 132 ++++++++-- ...italLoanBreachScheduleServiceImplTest.java | 11 +- 10 files changed, 363 insertions(+), 153 deletions(-) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature index 75443e0eb5b..28db2717368 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -700,3 +700,45 @@ Feature: Working Capital Breach Reschedule Action | PAUSE | 2026-01-04 | 2026-01-07 | # --- Close loan --- Then Admin closes the Working Capital loan with a full repayment on "04 January 2026" + + @TestRailId:C106762 + Scenario: Verify breach reschedule - UC21: a pause recorded after a reschedule leaves the periods that closed under the earlier frequency alone + 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 | 0 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 800 | 100000 | 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" + And 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 + # Period 1 ran and breached under the 6 day frequency + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | null | true | + | 2 | 2026-01-07 | 2026-01-12 | 6 | 400.00 | 400.00 | null | null | + # --- Breach reschedule: it settles the open period and every period after it, not the closed one --- + And Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 12 | DAYS | + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | null | true | + | 2 | 2026-01-07 | 2026-01-18 | 12 | 400.00 | 400.00 | null | null | + When Admin sets the business date to "09 January 2026" + # --- Breach pause: the replay must extend the rescheduled period and still leave period 1 on its own 6 days --- + And Admin initiate a Working Capital loan breach pause with startDate "09 January 2026" and endDate "10 January 2026" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | null | true | + | 2 | 2026-01-07 | 2026-01-20 | 14 | 400.00 | 400.00 | null | null | + And Working Capital loan breach action has the following data: + | action | startDate | endDate | + | RESCHEDULE | 2026-01-08 | | + | PAUSE | 2026-01-09 | 2026-01-10 | + # --- Close loan --- + Then Admin closes the Working Capital loan with a full repayment on "09 January 2026" diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java index 545009c905e..3d28c6da5ae 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBreachScheduleRepository.java @@ -56,12 +56,4 @@ Optional findByLoanIdAndFromDateLessThanEqualA AND s.toDate >= :businessDate""") Optional findCurrentOpenPeriod(@Param("loanId") Long loanId, @Param("businessDate") LocalDate businessDate); - - @Query(""" - SELECT s FROM WorkingCapitalLoanBreachSchedule s - WHERE s.loan.id = :loanId - AND s.fromDate > :businessDate - ORDER BY s.periodNumber ASC""") - List findFuturePeriodsOrderByPeriodNumberAsc(@Param("loanId") Long loanId, - @Param("businessDate") LocalDate businessDate); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolver.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolver.java index a13f5643825..fbbaaf3d19a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolver.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolver.java @@ -20,8 +20,10 @@ import java.time.LocalDate; import java.util.ArrayDeque; +import java.util.Comparator; import java.util.Deque; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -34,21 +36,31 @@ @RequiredArgsConstructor public class WorkingCapitalLoanActiveBreachResetResolver { + /** + * The order every replay of the breach actions has to follow: the timeline they describe, not the order they + * happened to be recorded in. A pause carries the date it was asked for rather than the date it was recorded on, so + * one recorded later can still sit earlier on the timeline. The creation instant orders two actions sharing a start + * date, and the identifier is the last tie-break for actions that are not persisted yet or were created within the + * same instant. + */ + private static final Comparator CHRONOLOGICAL = Comparator + .comparing(WorkingCapitalLoanBreachAction::getStartDate, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(action -> action.getCreatedDate().orElse(null), Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(WorkingCapitalLoanBreachAction::getId, Comparator.nullsLast(Comparator.naturalOrder())); + private final WorkingCapitalLoanBreachActionRepository breachActionRepository; /** - * Active resets, latest on top. Replays in recording order, not date order: action dates are the business date of - * their own request and are not monotonic. + * Active resets, latest on top: an undo cancels the reset above it and the top of the stack is the one that settled + * the schedule last. Resets and undos carry the business date, so {@link #CHRONOLOGICAL} is their recording order; + * replaying them through it keeps every replay on the one ordering rule. */ public Deque activeResets(final List actions) { final Deque stack = new ArrayDeque<>(); if (actions == null) { return stack; } - for (final WorkingCapitalLoanBreachAction action : actions) { - if (action == null) { - continue; - } + for (final WorkingCapitalLoanBreachAction action : chronological(actions)) { if (WorkingCapitalLoanBreachActionType.RESET.equals(action.getAction())) { stack.push(action); } else if (WorkingCapitalLoanBreachActionType.UNDO_RESET.equals(action.getAction()) && !stack.isEmpty()) { @@ -58,6 +70,17 @@ public Deque activeResets(final List chronological(final List actions) { + if (actions == null) { + return List.of(); + } + return actions.stream().filter(Objects::nonNull).sorted(CHRONOLOGICAL).toList(); + } + public Deque activeResets(final Long workingCapitalLoanId) { return activeResets(breachActionRepository.findByWorkingCapitalLoanIdOrderById(workingCapitalLoanId)); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java index f20dd7723e7..ebdbff82f12 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java @@ -69,10 +69,9 @@ public CommandProcessingResult createBreachAction(final Long workingCapitalLoanI log.debug("Created WC loan breach action {} for loan {}", breachAction.getAction(), workingCapitalLoanId); if (WorkingCapitalLoanBreachActionType.PAUSE.equals(breachAction.getAction()) - || WorkingCapitalLoanBreachActionType.RESUME.equals(breachAction.getAction())) { - breachScheduleService.recalculatePeriodsForPauses(workingCapitalLoan); - } else if (WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(breachAction.getAction())) { - breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan, breachAction); + || WorkingCapitalLoanBreachActionType.RESUME.equals(breachAction.getAction()) + || WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(breachAction.getAction())) { + breachScheduleService.replayForBreachAction(workingCapitalLoan, saved); } else if (WorkingCapitalLoanBreachActionType.RESET.equals(breachAction.getAction())) { breachResetService.resetBreach(workingCapitalLoan, saved); } else if (WorkingCapitalLoanBreachActionType.UNDO_RESET.equals(breachAction.getAction())) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java index a370deed08d..5159efa5263 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java @@ -50,9 +50,11 @@ public interface WorkingCapitalLoanBreachScheduleService { * carries a frequency group, the current open period is also re-dated: its toDate is recalculated from its fromDate * and the new frequency, extended by the recorded pauses that overlap the period. */ - void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction action); - - void recalculatePeriodsForPauses(WorkingCapitalLoan loan); + /** + * Replays the recorded breach actions over the schedule after {@code action} was recorded, re-dating the periods it + * reaches and rewriting their demand. + */ + void replayForBreachAction(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction action); void splitPeriodAtReset(WorkingCapitalLoan loan, LocalDate resetDate); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java index a81a372a636..5be2dcb92be 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java @@ -47,7 +47,6 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPausePeriod; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPausePeriodUtils; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodBounds; -import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanBreachScheduleMapper; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; @@ -66,6 +65,8 @@ @Service public class WorkingCapitalLoanBreachScheduleServiceImpl implements WorkingCapitalLoanBreachScheduleService { + private static final int FIRST_PERIOD_NUMBER = 1; + private final WorkingCapitalLoanBreachScheduleRepository repository; private final WorkingCapitalLoanBreachScheduleMapper mapper; private final WorkingCapitalLoanRepository loanRepository; @@ -117,8 +118,7 @@ public boolean generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final L return false; } - final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); - final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, params); + final List reschedules = reschedulesOnTheTimeline(loan.getId()); final List effectivePauses = findEffectivePauses(loan.getId()); final List newPeriods = new ArrayList<>(); @@ -126,10 +126,12 @@ public boolean generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final L while (!latestPeriod.getToDate().isAfter(businessDate)) { final int nextPeriodNumber = latestPeriod.getPeriodNumber() + 1; final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1); + final EffectiveBreachRescheduleParams params = paramsOverPeriod(loan, breachOpt.get(), reschedules, nextPeriodNumber, + newFromDate); final LocalDate newToDate = naturalToDate(loan, nextPeriodNumber, newFromDate, params); final WorkingCapitalLoanBreachSchedule nextPeriod = createPeriod(loan, nextPeriodNumber, newFromDate, newToDate, - minPaymentAmount); + calculateMinPaymentAmount(loan, params)); applyRecordedPauses(nextPeriod, effectivePauses); newPeriods.add(nextPeriod); latestPeriod = nextPeriod; @@ -231,77 +233,109 @@ public List retrieveBreachSchedule(final L } @Override - public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachAction action) { - final LocalDate businessDate = DateUtils.getBusinessLocalDate(); - final Optional breachOpt = getBreachConfig(loan); - if (breachOpt.isEmpty()) { - log.warn("No breach configuration found for WC loan {}, skipping reschedule", loan.getId()); - return; - } - final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); - final BigDecimal newMinPaymentAmount = calculateMinPaymentAmount(loan, params); - final Integer newFrequency = params.frequency(); - final WorkingCapitalLoanPeriodFrequencyType newFreqType = params.frequencyType(); - final boolean frequencyProvided = action.getFrequency() != null; - - repository.findCurrentOpenPeriod(loan.getId(), businessDate).ifPresent(currentPeriod -> { - currentPeriod.setBaseMinPaymentAmount(newMinPaymentAmount); - if (frequencyProvided) { - final LocalDate newToDate = resolveRescheduledToDate(loan.getId(), currentPeriod, action.getFrequency(), - action.getFrequencyType()); - setPeriodBounds(currentPeriod, currentPeriod.getFromDate(), newToDate); - } - currentPeriod.setMinPaymentAmount(newMinPaymentAmount); - currentPeriod.setOutstandingAmount(newMinPaymentAmount.subtract(currentPeriod.getPaidAmount()).max(BigDecimal.ZERO)); - currentPeriod.setNearBreach(null); - repository.saveAndFlush(currentPeriod); - - final List futurePeriods = repository.findFuturePeriodsOrderByPeriodNumberAsc(loan.getId(), - businessDate); - updateFuturePeriods(loan, currentPeriod, futurePeriods, newMinPaymentAmount, params); - }); - - applyActiveResetFlags(loan); - evaluateExpiredBreaches(loan, businessDate); - recalculatePastDueAmount(loan); - - log.debug("Rescheduled breach schedule for WC loan {}: new minimumPayment={} {}, frequency={} {}", loan.getId(), - params.minimumPayment(), params.minimumPaymentType(), newFrequency, newFreqType); - } - - private LocalDate resolveRescheduledToDate(final Long loanId, final WorkingCapitalLoanBreachSchedule currentPeriod, - final Integer frequency, final WorkingCapitalLoanPeriodFrequencyType frequencyType) { - return WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateRescheduledToDate(currentPeriod.getFromDate(), frequency, - frequencyType, breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId)); - } - - @Override - public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) { + public void replayForBreachAction(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachAction action) { final Optional breachOpt = getBreachConfig(loan); if (breachOpt.isEmpty()) { + log.warn("No breach configuration found for WC loan {}, the {} leaves the schedule as it is", loan.getId(), action.getAction()); return; } final List periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()); if (periods.isEmpty()) { return; } - final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get()); + replayPeriodsFrom(loan, breachOpt.get(), periods, replayStartPeriod(loan.getId(), action, periods)); + } + + /** + * Rebuilds the geometry of the periods from {@code firstPeriodNumber} on, in one forward walk over the recorded + * actions. + * + * Each period takes the natural length of the parameters governing it, then the pauses it meets, then the cut of a + * restart reset landing inside it, and its demand is rewritten when those parameters resolve to a different amount. + * The order is the rule itself: a pause moves the due date out, and a reset that lands on the period it re-dated + * takes the days back, because the reset date is a hard boundary and the restarted period starts on it. + */ + private void replayPeriodsFrom(final WorkingCapitalLoan loan, final WorkingCapitalBreach breach, + final List periods, final int firstPeriodNumber) { + final List replayed = periods.stream() + .filter(period -> period.getPeriodNumber() >= firstPeriodNumber).toList(); + if (replayed.isEmpty()) { + applyActiveResetFlags(loan.getId(), periods); + repository.saveAll(periods); + recalculatePastDueAmount(loan); + return; + } + final List reschedules = reschedulesOnTheTimeline(loan.getId()); final List effectivePauses = findEffectivePauses(loan.getId()); final List restartResetDates = activeRestartResetDates(loan.getId()); final LocalDate businessDate = DateUtils.getBusinessLocalDate(); - LocalDate fromDate = periods.getFirst().getFromDate(); - for (final WorkingCapitalLoanBreachSchedule period : periods) { + LocalDate fromDate = replayed.getFirst().getFromDate(); + for (final WorkingCapitalLoanBreachSchedule period : replayed) { + final WorkingCapitalLoanPeriodBounds before = new WorkingCapitalLoanPeriodBounds(period.getFromDate(), period.getToDate()); + final EffectiveBreachRescheduleParams params = paramsOverPeriod(loan, breach, reschedules, period.getPeriodNumber(), fromDate); period.setFromDate(fromDate); - period.setToDate(baseToDate(loan, period.getPeriodNumber(), fromDate, params, restartResetDates)); + period.setToDate(naturalToDate(loan, period.getPeriodNumber(), fromDate, params)); applyRecordedPauses(period, effectivePauses); + closeAtRestartReset(period, restartResetDates); + applyResolvedDemand(period, calculateMinPaymentAmount(loan, params), before); recomputeBreach(period, businessDate); fromDate = period.getToDate().plusDays(1); } applyActiveResetFlags(loan.getId(), periods); repository.saveAll(periods); recalculatePastDueAmount(loan); - log.debug("Recalculated breach schedule periods for WC loan {} by replaying {} effective pauses", loan.getId(), - effectivePauses.size()); + log.debug("Replayed breach schedule periods {}..{} of WC loan {} over {} effective pauses", firstPeriodNumber, + replayed.getLast().getPeriodNumber(), loan.getId(), effectivePauses.size()); + } + + /** + * The first period the replay has to touch: the one the action lands on. + * + * An action only reaches the period holding its date and the ones after it, so when nothing on the timeline comes + * after it the earlier periods cannot have moved and replaying them would only risk re-dating settled history. + * Behind an action that was already recorded the schedule is rebuilt from the start instead: the actions after it + * have to be re-derived over the geometry it changes. + * + * An action dated past the end of the schedule lands on no period at all. It is left to the generation, which + * applies the same rules to the periods it creates - taking the last, already expired period as the landing one + * would re-date it under parameters that were not in force while it ran. + */ + private int replayStartPeriod(final Long loanId, final WorkingCapitalLoanBreachAction action, + final List periods) { + if (!isLastOnTheTimeline(loanId, action)) { + return FIRST_PERIOD_NUMBER; + } + return periods.stream() // + .filter(period -> !DateUtils.isBefore(action.getStartDate(), period.getFromDate()) + && !DateUtils.isAfter(action.getStartDate(), period.getToDate())) // + .findFirst() // + .map(WorkingCapitalLoanBreachSchedule::getPeriodNumber) // + .orElseGet(() -> periods.getLast().getPeriodNumber() + 1); + } + + private boolean isLastOnTheTimeline(final Long loanId, final WorkingCapitalLoanBreachAction action) { + final List ordered = WorkingCapitalLoanActiveBreachResetResolver + .chronological(breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId)); + return !ordered.isEmpty() && Objects.equals(ordered.getLast().getId(), action.getId()); + } + + /** + * Writes the demand of a period when the parameters governing it resolve to a different amount, and drops the near + * breach when either that amount or the dates it was raised against have moved. A replay that changed neither + * leaves the period exactly as it found it, so it stays idempotent. + */ + private void applyResolvedDemand(final WorkingCapitalLoanBreachSchedule period, final BigDecimal minPaymentAmount, + final WorkingCapitalLoanPeriodBounds before) { + final boolean demandChanged = !MathUtil.isEqualTo(period.getBaseMinPaymentAmount(), minPaymentAmount); + if (demandChanged) { + period.setBaseMinPaymentAmount(minPaymentAmount); + period.setMinPaymentAmount(minPaymentAmount); + period.setOutstandingAmount(MathUtil.subtract(minPaymentAmount, period.getPaidAmount()).max(BigDecimal.ZERO)); + } + if (demandChanged || !DateUtils.isEqual(before.fromDate(), period.getFromDate()) + || !DateUtils.isEqual(before.toDate(), period.getToDate())) { + period.setNearBreach(null); + } } @Override @@ -480,27 +514,30 @@ private LocalDate naturalToDate(final WorkingCapitalLoan loan, final int periodN } /** - * End date of a period before the pauses are replayed on top of it: the natural end, closed one day early when a - * reset that restarted the schedule falls inside the period. + * Closes a period the day before a reset that restarted the schedule, when the reset date falls inside it. * * A restart reset cuts its period short and starts a new one on the reset date, and that cut is not recoverable * from the frequency alone. Rebuilding the geometry from the natural lengths only would stretch the cut period back * to full length and swallow the period the reset started, which also moves the reset flag onto the merged period * and un-evaluates a breach that was already settled. + * + * The cut runs after the pauses, not before: a pause recorded inside the period before the reset extends that + * period again on every replay, and a cut applied first would simply be re-opened by it. The reset date is a hard + * boundary, so the paused days that would push the period past it are dropped rather than carried over: the + * restarted period begins on the reset date and only the pauses reaching into it move its own end date. */ - private LocalDate baseToDate(final WorkingCapitalLoan loan, final int periodNumber, final LocalDate fromDate, - final EffectiveBreachRescheduleParams params, final List restartResetDates) { - final LocalDate naturalToDate = naturalToDate(loan, periodNumber, fromDate, params); - return restartResetDates.stream() // - .filter(resetDate -> resetDate.isAfter(fromDate) && !resetDate.isAfter(naturalToDate)) // + private void closeAtRestartReset(final WorkingCapitalLoanBreachSchedule period, final List restartResetDates) { + restartResetDates.stream() // + .filter(resetDate -> resetDate.isAfter(period.getFromDate()) && !resetDate.isAfter(period.getToDate())) // .min(Comparator.naturalOrder()) // - .map(resetDate -> resetDate.minusDays(1)) // - .orElse(naturalToDate); + .ifPresent(resetDate -> setPeriodBounds(period, period.getFromDate(), resetDate.minusDays(1))); } /** * Start dates of the active resets that restarted the schedule. A reset that only flags its period leaves the - * geometry untouched and is therefore not a cut. + * geometry untouched and is therefore not a cut. A reset always falls inside an existing period and splits it on + * the spot, so the generation never meets one: only the replay, which rebuilds a period the reset already cut, has + * to put the cut back. */ private List activeRestartResetDates(final Long loanId) { return activeBreachResetResolver.activeResets(loanId).stream() // @@ -550,12 +587,43 @@ private EffectiveBreachRescheduleParams resolveEffectiveRescheduleParams(final L private EffectiveBreachRescheduleParams resolveEffectiveRescheduleParams(final Long loanId, final WorkingCapitalBreach breach, final Long beforeActionId) { - final List reschedules = breachActionRepository - .findByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, WorkingCapitalLoanBreachActionType.RESCHEDULE).stream() + final List reschedules = reschedulesOnTheTimeline(loanId).stream() .filter(action -> beforeActionId == null || action.getId() < beforeActionId).toList(); - final Optional latestWithPayment = reschedules.stream() + return effectiveParamsAsOf(breach, reschedules, null); + } + + /** The recorded reschedules on their own timeline, earliest first. */ + private List reschedulesOnTheTimeline(final Long loanId) { + return WorkingCapitalLoanActiveBreachResetResolver.chronological(breachActionRepository + .findByWorkingCapitalLoanIdAndActionOrderByIdDesc(loanId, WorkingCapitalLoanBreachActionType.RESCHEDULE)); + } + + /** + * The parameters governing a period: the ones in force when it opens, then the latest reschedule landing inside the + * span they would give it. + * + * A reschedule settles the period it falls in, the way a reset does, and every period after it. A period that + * closed before it therefore keeps the parameters of its own time - resolving the whole schedule against the latest + * reschedule instead would re-date periods that expired under an earlier frequency. + */ + private EffectiveBreachRescheduleParams paramsOverPeriod(final WorkingCapitalLoan loan, final WorkingCapitalBreach breach, + final List reschedules, final int periodNumber, final LocalDate fromDate) { + final EffectiveBreachRescheduleParams atOpening = effectiveParamsAsOf(breach, reschedules, fromDate); + return effectiveParamsAsOf(breach, reschedules, naturalToDate(loan, periodNumber, fromDate, atOpening)); + } + + /** + * The parameters in force on a date, or the latest recorded ones when no date is given. The payment and the + * frequency are resolved independently: a reschedule can carry either group on its own and leaves the other as it + * was. + */ + private EffectiveBreachRescheduleParams effectiveParamsAsOf(final WorkingCapitalBreach breach, + final List reschedulesOnTheTimeline, final LocalDate asOf) { + final List inForce = reschedulesOnTheTimeline.stream() + .filter(action -> asOf == null || !DateUtils.isAfter(action.getStartDate(), asOf)).toList().reversed(); + final Optional latestWithPayment = inForce.stream() .filter(action -> action.getMinimumPayment() != null).findFirst(); - final Optional latestWithFrequency = reschedules.stream() + final Optional latestWithFrequency = inForce.stream() .filter(action -> action.getFrequency() != null).findFirst(); return new EffectiveBreachRescheduleParams( @@ -587,30 +655,6 @@ private boolean isBreachEvaluationDisabled(final Long loanId, final LocalDate da return breachActionRepository.isBreachDisabledAsOf(loanId, date); } - private void updateFuturePeriods(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachSchedule currentPeriod, - final List existingFuturePeriods, final BigDecimal minPaymentAmount, - final EffectiveBreachRescheduleParams params) { - int periodNumber = currentPeriod.getPeriodNumber(); - LocalDate fromDate = currentPeriod.getToDate().plusDays(1); - - for (final WorkingCapitalLoanBreachSchedule period : existingFuturePeriods) { - periodNumber++; - final LocalDate toDate = naturalToDate(loan, periodNumber, fromDate, params); - - period.setPeriodNumber(periodNumber); - setPeriodBounds(period, fromDate, toDate); - period.setBaseMinPaymentAmount(minPaymentAmount); - period.setMinPaymentAmount(minPaymentAmount); - period.setPaidAmount(BigDecimal.ZERO); - period.setOutstandingAmount(minPaymentAmount); - period.setNearBreach(null); - period.setBreach(null); - - fromDate = toDate.plusDays(1); - } - repository.saveAll(existingFuturePeriods); - } - @Override public void applyActiveResetFlags(final WorkingCapitalLoan loan) { applyActiveResetFlags(loan.getId(), repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId())); 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 182430ba4f8..0bc282cc902 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,7 +35,6 @@ 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; @@ -408,16 +407,18 @@ private int getBreachGraceDays(final WorkingCapitalLoan workingCapitalLoan) { * 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. + * + * The boundary is the reset on top of the resolver stack, which replays the actions on their own timeline. Scanning + * the active resets here for the highest date would be a second definition of "latest" to keep in step with the + * resolver, for an answer the resolver already holds. */ private void validateNotBeforeBreachReset(final DataValidatorBuilder dataValidator, final LocalDate startDate, final List existing) { if (startDate == null) { return; } - activeBreachResetResolver.activeResets(existing).stream() // + Optional.ofNullable(activeBreachResetResolver.activeResets(existing).peek()) // .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)); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolverTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolverTest.java index 9a6394553a5..5ab7ddb1384 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolverTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanActiveBreachResetResolverTest.java @@ -24,6 +24,8 @@ import static org.mockito.Mockito.when; import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Deque; import java.util.List; import java.util.Optional; @@ -59,6 +61,11 @@ private WorkingCapitalLoanBreachAction action(final long id, final WorkingCapita return action; } + private WorkingCapitalLoanBreachAction createdAt(final WorkingCapitalLoanBreachAction action, final int hour) { + action.setCreatedDate(OffsetDateTime.of(2026, 4, 15, hour, 0, 0, 0, ZoneOffset.UTC)); + return action; + } + private static List ids(final Deque stack) { return stack.stream().map(WorkingCapitalLoanBreachAction::getId).toList(); } @@ -111,8 +118,9 @@ void activeResets_aSecondUndoCancelsTheEarlierReset() { assertTrue(actual.isEmpty()); } + /** Reset 1 was recorded first but is dated last, so the undo of 04-10 meets only reset 2 and reset 1 survives. */ @Test - void activeResets_pairsByRecordingOrderNotByDate() { + void activeResets_anUndoCancelsTheResetThatPrecedesItOnTheTimeline() { final Deque actual = underTest .activeResets(List.of(action(1L, WorkingCapitalLoanBreachActionType.RESET, LocalDate.of(2026, 5, 20)), action(2L, WorkingCapitalLoanBreachActionType.RESET, LocalDate.of(2026, 4, 5)), @@ -121,6 +129,26 @@ void activeResets_pairsByRecordingOrderNotByDate() { assertEquals(List.of(1L), ids(actual)); } + /** Two actions on the same start date are ordered by the instant they were created. */ + @Test + void activeResets_theCreationInstantOrdersTheActionsSharingAStartDate() { + final LocalDate sameDay = LocalDate.of(2026, 4, 15); + final WorkingCapitalLoanBreachAction undo = createdAt(action(1L, WorkingCapitalLoanBreachActionType.UNDO_RESET, sameDay), 12); + final WorkingCapitalLoanBreachAction reset = createdAt(action(2L, WorkingCapitalLoanBreachActionType.RESET, sameDay), 9); + + assertEquals(List.of(), ids(underTest.activeResets(List.of(undo, reset))), "the reset was created first, so the undo cancels it"); + } + + @Test + void activeResets_theIdentifierBreaksATieOnTheCreationInstantToo() { + final LocalDate sameDay = LocalDate.of(2026, 4, 15); + final Deque actual = underTest + .activeResets(List.of(action(2L, WorkingCapitalLoanBreachActionType.UNDO_RESET, sameDay), + action(1L, WorkingCapitalLoanBreachActionType.RESET, sameDay))); + + assertTrue(actual.isEmpty()); + } + @Test void activeResets_ignoresTheOtherActionTypesAndAnUndoWithoutAReset() { final Deque actual = underTest @@ -133,7 +161,7 @@ void activeResets_ignoresTheOtherActionTypesAndAnUndoWithoutAReset() { } @Test - void findLatestActiveReset_replaysTheLoanActionsInRecordingOrder() { + void findLatestActiveReset_replaysTheLoanActionsOnTheirTimeline() { when(breachActionRepository.findByWorkingCapitalLoanIdOrderById(LOAN_ID)) .thenReturn(List.of(action(1L, WorkingCapitalLoanBreachActionType.RESET, LocalDate.of(2026, 4, 15)), action(2L, WorkingCapitalLoanBreachActionType.RESET, LocalDate.of(2026, 5, 20)), diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java index f60843eff62..2e9ee3614fe 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachResetFlagDerivationTest.java @@ -21,7 +21,6 @@ import static org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType.BUSINESS_DATE; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -222,9 +221,10 @@ void pauseAfterRestartReset_keepsTheSplitAndExtendsOnlyTheRestartedPeriod() { period(1, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 1), false); period(2, LocalDate.of(2026, 3, 2), LocalDate.of(2026, 4, 14), false); period(3, resetDate, LocalDate.of(2026, 6, 13), true); - givenActions(restartReset(1L, resetDate), pause(2L, LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 29))); + final WorkingCapitalLoanBreachAction pause = pause(2L, LocalDate.of(2026, 4, 20), LocalDate.of(2026, 4, 29)); + givenActions(restartReset(1L, resetDate), pause); - scheduleService.recalculatePeriodsForPauses(loan); + scheduleService.replayForBreachAction(loan, pause); final String state = dump(); final WorkingCapitalLoanBreachSchedule holdingReset = periodContaining(resetDate); @@ -241,31 +241,34 @@ void pauseAfterRestartReset_keepsTheSplitAndExtendsOnlyTheRestartedPeriod() { } /** - * Reset A on 06-10, business date back to 05-05, reset B, forward to 06-15, undo B: A lies beyond the restored - * period. + * The rebuild from the start, taken when an action sits behind later ones on the timeline. A pause recorded inside + * the period before the restart reset re-extends that period on every such replay, so the cut has to be applied + * after the pauses. Cutting first lets the earlier pause re-open it: period 2 ends on 01-15 and swallows the reset + * date instead of stopping on 01-11. */ @Test - void undoAfterBackwardsBusinessDateMove_flagsThePeriodHoldingTheStillActiveReset() { - givenBreachConfig(30); - final WorkingCapitalLoanBreachAction resetA = restartReset(1L, LocalDate.of(2026, 6, 10)); - final WorkingCapitalLoanBreachAction resetB = restartReset(2L, LocalDate.of(2026, 5, 5)); - final WorkingCapitalLoanBreachAction undoB = action(3L, WorkingCapitalLoanBreachActionType.UNDO_RESET, LocalDate.of(2026, 6, 15)); - period(1, LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30), false); - period(2, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 4), false); - period(3, LocalDate.of(2026, 5, 5), LocalDate.of(2026, 6, 3), true); - givenActions(resetA, resetB, undoB); - businessDate(LocalDate.of(2026, 6, 15)); - - resetService.undoResetBreach(loan, undoB, List.of(resetA, resetB)); + void pauseBehindLaterActions_rebuildsFromTheStartWithoutReopeningTheCut() { + givenBreachConfig(6); + businessDate(LocalDate.of(2026, 1, 14)); + final LocalDate resetDate = LocalDate.of(2026, 1, 12); + period(1, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 6), false); + period(2, LocalDate.of(2026, 1, 7), LocalDate.of(2026, 1, 11), false); + period(3, resetDate, LocalDate.of(2026, 1, 17), true); + final WorkingCapitalLoanBreachAction earlierPause = pause(1L, LocalDate.of(2026, 1, 9), LocalDate.of(2026, 1, 10)); + givenActions(earlierPause, restartReset(2L, resetDate), pause(3L, resetDate, LocalDate.of(2026, 1, 13))); + + scheduleService.replayForBreachAction(loan, earlierPause); final String state = dump(); - final WorkingCapitalLoanBreachSchedule holdingA = periodContaining(resetA.getStartDate()); + final WorkingCapitalLoanBreachSchedule holdingReset = periodContaining(resetDate); assertAll(state, // - () -> assertTrue(holdingA.isReset(), "period holding reset A (06-10) is flagged"), - () -> assertFalse(periodContaining(LocalDate.of(2026, 5, 15)).isReset(), "restored P2 is not flagged"), - () -> assertEquals(1, sorted().stream().filter(WorkingCapitalLoanBreachSchedule::isReset).count(), "one flag"), - () -> assertEquals(0, BigDecimal.ZERO.compareTo(balance.getBreachPastDueAmount()), - "past due anchored on the open period holding A, so zero")); + () -> assertEquals(LocalDate.of(2026, 1, 11), sorted().get(1).getToDate(), + "the pause recorded before the reset must not re-open the cut"), + () -> assertEquals(LocalDate.of(2026, 1, 19), sorted().get(2).getToDate(), + "only the restarted period absorbs the 2 days of the pause recorded on the reset date"), + () -> assertEquals(3, holdingReset.getPeriodNumber(), "the restarted period 3 still holds the reset date"), + () -> assertTrue(holdingReset.isReset(), "period holding the reset date is flagged"), + () -> assertEquals(1, sorted().stream().filter(WorkingCapitalLoanBreachSchedule::isReset).count(), "one flag")); } @Test @@ -391,9 +394,8 @@ void rescheduleAfterABackwardsBusinessDateMove_movesTheFlagWithTheResetDate() { when(breachActionRepository.findByWorkingCapitalLoanIdAndActionOrderByIdDesc(LOAN_ID, WorkingCapitalLoanBreachActionType.RESCHEDULE)).thenReturn(List.of(reschedule)); when(repository.findCurrentOpenPeriod(LOAN_ID, LocalDate.of(2026, 5, 5))).thenReturn(Optional.of(current)); - when(repository.findFuturePeriodsOrderByPeriodNumberAsc(LOAN_ID, LocalDate.of(2026, 5, 5))).thenReturn(List.of(future)); - scheduleService.rescheduleMinimumPayment(loan, reschedule); + scheduleService.replayForBreachAction(loan, reschedule); assertAll(dump(), // () -> assertEquals(LocalDate.of(2026, 5, 10), current.getToDate()), @@ -451,6 +453,82 @@ void undoWithoutAnActiveReset_clearsEveryFlagAndRecalculatesThePastDueAmount() { () -> assertEquals(0, BigDecimal.valueOf(100).compareTo(balance.getBreachPastDueAmount()))); } + /** + * A reschedule is replayed like any other action. The periods that closed under the earlier frequency keep it, the + * period the reschedule lands on and the ones after it take the new one, and the pauses they meet still move their + * due dates out. Resolving the whole schedule against the latest frequency instead would stretch period 1, which + * expired weeks before the reschedule was even requested. + */ + @Test + void reschedule_keepsTheClosedPeriodsAndThePausesOfTheOnesItReaches() { + givenBreachConfig(30); + businessDate(LocalDate.of(2026, 5, 5)); + period(1, LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30), false); + period(2, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 30), false); + // period 3 already carries the 5 days of the pause, as the replay of the pause left it + period(3, LocalDate.of(2026, 5, 31), LocalDate.of(2026, 7, 4), false); + final WorkingCapitalLoanBreachAction pause = pause(1L, LocalDate.of(2026, 6, 10), LocalDate.of(2026, 6, 14)); + final WorkingCapitalLoanBreachAction reschedule = action(2L, WorkingCapitalLoanBreachActionType.RESCHEDULE, + LocalDate.of(2026, 5, 5)); + reschedule.setFrequency(40); + reschedule.setFrequencyType(WorkingCapitalLoanPeriodFrequencyType.DAYS); + givenActions(pause, reschedule); + when(breachActionRepository.findByWorkingCapitalLoanIdAndActionOrderByIdDesc(LOAN_ID, + WorkingCapitalLoanBreachActionType.RESCHEDULE)).thenReturn(List.of(reschedule)); + + scheduleService.replayForBreachAction(loan, reschedule); + + assertBounds(periodNumber(1), LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30), 30); + assertBounds(periodNumber(2), LocalDate.of(2026, 5, 1), LocalDate.of(2026, 6, 9), 40); + assertBounds(periodNumber(3), LocalDate.of(2026, 6, 10), LocalDate.of(2026, 7, 24), 45); + + final String afterTheReschedule = dump(); + scheduleService.replayForBreachAction(loan, pause); + assertEquals(afterTheReschedule, dump(), "replaying again must land on the same schedule"); + } + + /** + * Nothing on the timeline comes after this pause, so the periods before the one it lands on cannot have moved and + * are left exactly as the earlier actions settled them - period 2 keeps the 3 days a reset gave it instead of being + * stretched back to the frequency. + */ + @Test + void lastActionOnTheTimeline_replaysOnlyFromThePeriodItLandsOn() { + givenBreachConfig(6); + businessDate(LocalDate.of(2026, 1, 20)); + period(1, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 6), false); + period(2, LocalDate.of(2026, 1, 7), LocalDate.of(2026, 1, 9), false); + period(3, LocalDate.of(2026, 1, 10), LocalDate.of(2026, 1, 15), false); + final WorkingCapitalLoanBreachAction pause = pause(1L, LocalDate.of(2026, 1, 11), LocalDate.of(2026, 1, 12)); + givenActions(pause); + + scheduleService.replayForBreachAction(loan, pause); + + assertBounds(periodNumber(2), LocalDate.of(2026, 1, 7), LocalDate.of(2026, 1, 9), 3); + assertBounds(periodNumber(3), LocalDate.of(2026, 1, 10), LocalDate.of(2026, 1, 17), 8); + } + + /** + * The same pause, now behind a later one on the timeline: the actions after it have to be re-derived over the + * geometry it changes, so the schedule is rebuilt from the start and period 2 takes its natural length back. + */ + @Test + void actionBehindALaterOne_rebuildsTheScheduleFromTheStart() { + givenBreachConfig(6); + businessDate(LocalDate.of(2026, 1, 20)); + period(1, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 6), false); + period(2, LocalDate.of(2026, 1, 7), LocalDate.of(2026, 1, 9), false); + period(3, LocalDate.of(2026, 1, 10), LocalDate.of(2026, 1, 15), false); + final WorkingCapitalLoanBreachAction pause = pause(1L, LocalDate.of(2026, 1, 11), LocalDate.of(2026, 1, 12)); + givenActions(pause, pause(2L, LocalDate.of(2026, 1, 20), LocalDate.of(2026, 1, 21))); + + scheduleService.replayForBreachAction(loan, pause); + + assertBounds(periodNumber(1), LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 6), 6); + assertBounds(periodNumber(2), LocalDate.of(2026, 1, 7), LocalDate.of(2026, 1, 14), 8); + assertBounds(periodNumber(3), LocalDate.of(2026, 1, 15), LocalDate.of(2026, 1, 22), 8); + } + private WorkingCapitalLoanBreachSchedule periodNumber(final int number) { return sorted().stream().filter(p -> p.getPeriodNumber() == number).findFirst().orElseThrow(); } @@ -470,7 +548,7 @@ void undoAfterAPauseOnAGraceDaysLoan_restoresThePauseAndGraceExtendedFirstPeriod final WorkingCapitalLoanBreachAction pause = pause(1L, LocalDate.of(2026, 2, 10), LocalDate.of(2026, 2, 14)); givenActions(pause); businessDate(LocalDate.of(2026, 2, 15)); - scheduleService.recalculatePeriodsForPauses(loan); + scheduleService.replayForBreachAction(loan, pause); assertBounds(periodNumber(1), LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 9), 68); final WorkingCapitalLoanBreachAction reset = restartReset(2L, LocalDate.of(2026, 2, 20)); @@ -498,7 +576,7 @@ void undoOfANoSplitResetAfterAPauseOnAGraceDaysLoan_leavesTheScheduleUntouched() final WorkingCapitalLoanBreachAction pause = pause(1L, LocalDate.of(2026, 2, 10), LocalDate.of(2026, 2, 14)); givenActions(pause); businessDate(LocalDate.of(2026, 2, 15)); - scheduleService.recalculatePeriodsForPauses(loan); + scheduleService.replayForBreachAction(loan, pause); scheduleService.generateNextPeriodIfNeeded(loan, LocalDate.of(2026, 3, 10)); final WorkingCapitalLoanBreachSchedule first = periodNumber(1); final WorkingCapitalLoanBreachSchedule second = periodNumber(2); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImplTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImplTest.java index e62c48b3f6a..3f913587a1b 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImplTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImplTest.java @@ -324,7 +324,7 @@ void applyRepaymentUndo_skipsWhenBreachEvaluationDisabled() { } @Test - void recalculatePeriodsForPauses_recalculatesPastDueAmount() { + void replayForBreachAction_recalculatesPastDueAmount() { final WorkingCapitalBreach breachConfig = new WorkingCapitalBreach(); breachConfig.setBreachFrequency(7); breachConfig.setBreachFrequencyType(WorkingCapitalLoanPeriodFrequencyType.DAYS); @@ -341,23 +341,24 @@ void recalculatePeriodsForPauses_recalculatesPastDueAmount() { when(breachActionRepository.findByWorkingCapitalLoanIdAndActionOrderByIdDesc(anyLong(), any())).thenReturn(List.of()); when(balanceRepository.findByWcLoan_Id(LOAN_ID)).thenReturn(Optional.of(balance)); - underTest.recalculatePeriodsForPauses(loan); + underTest.replayForBreachAction(loan, pause(LocalDate.of(2026, 5, 2), LocalDate.of(2026, 5, 3))); assertEquals(0, BigDecimal.valueOf(100).compareTo(balance.getBreachPastDueAmount())); } @Test - void recalculatePeriodsForPauses_keepsTheGraceDaysOfTheFirstPeriod() { + void replayForBreachAction_keepsTheGraceDaysOfTheFirstPeriod() { givenBreachConfig(60, WorkingCapitalLoanPeriodFrequencyType.DAYS); loan.getLoanProductRelatedDetails().setBreachGraceDays(3); givenReschedules(); - givenActions(pause(LocalDate.of(2026, 2, 10), LocalDate.of(2026, 2, 14))); + final WorkingCapitalLoanBreachAction pause = pause(LocalDate.of(2026, 2, 10), LocalDate.of(2026, 2, 14)); + givenActions(pause); final WorkingCapitalLoanBreachSchedule firstPeriod = period(1, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 4), 63); final WorkingCapitalLoanBreachSchedule secondPeriod = period(2, LocalDate.of(2026, 3, 5), LocalDate.of(2026, 5, 3), 60); when(repository.findByLoanIdOrderByPeriodNumberAsc(LOAN_ID)).thenReturn(List.of(firstPeriod, secondPeriod)); when(balanceRepository.findByWcLoan_Id(LOAN_ID)).thenReturn(Optional.of(balance)); - underTest.recalculatePeriodsForPauses(loan); + underTest.replayForBreachAction(loan, pause); assertBounds(firstPeriod, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 9), 68); assertBounds(secondPeriod, LocalDate.of(2026, 3, 10), LocalDate.of(2026, 5, 8), 60); From 095c96af918d30ebeb2a94277b1ea5ea46c8e7fe Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Mon, 21 Sep 2026 14:52:47 +0200 Subject: [PATCH 3/3] FINERACT-2455: updated e2e scenario for validation of Working Capital - No backdated breach pause --- .../WorkingCapitalBreachPause.feature | 209 ++++++++++++++++++ ...WorkingCapitalLoanBreachResetUndoTest.java | 37 ---- 2 files changed, 209 insertions(+), 37 deletions(-) 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 97e87744258..c003868ac7c 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachPause.feature @@ -955,6 +955,7 @@ Feature: Working Capital Breach Pause | 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" + @TestRailId:C106731 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 @@ -994,3 +995,211 @@ Feature: Working Capital Breach Pause | 2 | 2026-01-07 | 2026-01-11 | 5 | 400 | 400 | null | true | | 3 | 2026-01-12 | 2026-01-19 | 8 | 400 | 400 | null | null | Then Admin closes the Working Capital loan with a full repayment on "12 January 2026" + + @TestRailId:C106732 + Scenario: Verify working capital loan breach pause - a pause recorded before a restart reset keeps the reset split when a later pause is added + 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 "09 January 2026" + # --- Pause recorded before the reset extends period 2 by 2 days --- + And Admin initiate a Working Capital loan breach pause with startDate "09 January 2026" and endDate "10 January 2026" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-14 | 8 | 400.00 | 400.00 | null | false | + When Admin sets the business date to "12 January 2026" + And Admin creates WC breach reset action with restart period from reset date + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-11 | 5 | 400.00 | 400.00 | true | false | + | 3 | 2026-01-12 | 2026-01-17 | 6 | 400.00 | 400.00 | null | true | + # --- A valid pause after the reset must extend only the restarted period; the earlier pause must not re-open the cut --- + 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 | + | PAUSE | 2026-01-09 | 2026-01-10 | + | RESET | 2026-01-12 | | + | PAUSE | 2026-01-12 | 2026-01-13 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-11 | 5 | 400.00 | 400.00 | true | false | + | 3 | 2026-01-12 | 2026-01-19 | 8 | 400.00 | 400.00 | null | true | + Then Admin closes the Working Capital loan with a full repayment on "12 January 2026" + + @TestRailId:C106733 + Scenario: Verify working capital loan breach pause - a flag-only breach reset also rejects a backdated pause and a pause on its date only extends the flagged period + 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 + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-12 | 6 | 400.00 | 400.00 | null | true | + Then Initiating a Working Capital loan breach pause with startDate "11 January 2026" and endDate "13 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 | + 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 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-14 | 8 | 400.00 | 400.00 | null | true | + Then Admin closes the Working Capital loan with a full repayment on "12 January 2026" + + @TestRailId:C106734 + Scenario: Verify working capital loan breach pause - an undone breach reset no longer blocks a backdated pause and the restored period absorbs it + 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 + 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 | + When Admin creates WC breach undo reset action + # The undo restores period 2 to its natural length; period 3 is generated because the business date is the last day of period 2 + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-12 | 6 | 400.00 | 400.00 | null | false | + | 3 | 2026-01-13 | 2026-01-18 | 6 | 400.00 | 400.00 | null | false | + And Admin initiate a Working Capital loan breach pause with startDate "09 January 2026" and endDate "10 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | RESET | 2026-01-12 | | + | UNDO_RESET | 2026-01-12 | | + | PAUSE | 2026-01-09 | 2026-01-10 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-14 | 8 | 400.00 | 400.00 | null | false | + | 3 | 2026-01-15 | 2026-01-20 | 6 | 400.00 | 400.00 | null | false | + Then Admin closes the Working Capital loan with a full repayment on "12 January 2026" + + @TestRailId:C106735 + Scenario: Verify working capital loan breach pause - the latest active breach reset is the pause boundary and the earlier reset takes over after an undo + 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 + And Admin creates WC breach reset action with restart period from reset date + When Admin sets the business date to "14 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin creates WC breach reset action with restart period from reset date + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-07 | 1 | 400.00 | 400.00 | true | false | + | 3 | 2026-01-08 | 2026-01-13 | 6 | 400.00 | 400.00 | true | true | + | 4 | 2026-01-14 | 2026-01-19 | 6 | 400.00 | 400.00 | null | true | + # --- The latest active reset (14 Jan) is the boundary, even for a date after the earlier reset --- + Then Initiating a Working Capital loan breach pause with startDate "10 January 2026" and endDate "11 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Breach pause cannot start before the latest breach reset date: 2026-01-14 | + # --- Undoing the latest reset makes the earlier one the boundary --- + When Admin creates WC breach undo reset action + Then Initiating a Working Capital loan breach pause with startDate "05 January 2026" and endDate "06 January 2026" results an error with the following data: + | httpCode | message | + | 400 | Breach pause cannot start before the latest breach reset date: 2026-01-08 | + And Admin initiate a Working Capital loan breach pause with startDate "10 January 2026" and endDate "11 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | RESET | 2026-01-08 | | + | RESET | 2026-01-14 | | + | UNDO_RESET | 2026-01-14 | | + | PAUSE | 2026-01-10 | 2026-01-11 | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-07 | 1 | 400.00 | 400.00 | true | false | + | 3 | 2026-01-08 | 2026-01-15 | 8 | 400.00 | 400.00 | null | true | + | 4 | 2026-01-16 | 2026-01-21 | 6 | 400.00 | 400.00 | null | false | + Then Admin closes the Working Capital loan with a full repayment on "14 January 2026" + + @TestRailId:C106785 + Scenario: Verify working capital loan breach pause - a resume after a post-reset pause shortens only the restarted period and keeps the reset cut + 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 "09 January 2026" + And Admin initiate a Working Capital loan breach pause with startDate "09 January 2026" and endDate "10 January 2026" + When Admin sets the business date to "12 January 2026" + And Admin creates WC breach reset action with restart period from reset date + And Admin initiate a Working Capital loan breach pause with startDate "12 January 2026" and endDate "15 January 2026" + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-11 | 5 | 400.00 | 400.00 | true | false | + | 3 | 2026-01-12 | 2026-01-21 | 10 | 400.00 | 400.00 | null | true | + # --- Resume on 13 Jan: the pause is effectively 12-13 Jan, the replay must keep the cut --- + When Admin sets the business date to "13 January 2026" + And Admin initiate a Working Capital loan breach resume with startDate "13 January 2026" + Then Working Capital loan breach action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-09 | 2026-01-10 | + | RESET | 2026-01-12 | | + | PAUSE | 2026-01-12 | 2026-01-15 | + | RESUME | 2026-01-13 | | + And Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | breach | reset | + | 1 | 2026-01-01 | 2026-01-06 | 6 | 400.00 | 400.00 | true | false | + | 2 | 2026-01-07 | 2026-01-11 | 5 | 400.00 | 400.00 | true | false | + | 3 | 2026-01-12 | 2026-01-19 | 8 | 400.00 | 400.00 | null | true | + Then Admin closes the Working Capital loan with a full repayment on "13 January 2026" diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java index d637cb5f963..ae3447c3f8f 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanBreachResetUndoTest.java @@ -467,41 +467,4 @@ void pauseOnTheResetDate_extendsOnlyTheRestartedPeriod() { action("PAUSE", "2026-01-12")); }); } - - @Test - @DisplayName("Undo after a backwards business date move flags the period regenerated for the still active reset") - void undoAfterBackwardsBusinessDateMove_flagsTheRegeneratedPeriodOfTheStillActiveReset() { - runAt("2026-01-01", () -> { - final Long loanId = setupCommonBreachLoan(); - - advanceBusinessDateWithCob(loanId, "2026-02-15", "2026-03-03"); - advanceBusinessDateWithCob(loanId, "2026-03-03", "2026-05-10"); - createBreachResetWithRestartPeriod(loanId); - validateBreachSchedule(getBreachSchedule(loanId), // - period(1, "2026-01-01", "2026-03-01", 60, "400.00", "100.00", true, false), // - period(2, "2026-03-02", "2026-04-30", 60, "400.00", "400.00", true, false), // - period(3, "2026-05-01", "2026-05-09", 9, "400.00", "400.00", true, false), // - period(4, "2026-05-10", "2026-07-08", 60, "400.00", "400.00", null, true)); - - setBusinessDate("2026-03-20"); - createBreachResetWithRestartPeriod(loanId); - validateBreachSchedule(getBreachSchedule(loanId), // - period(1, "2026-01-01", "2026-03-01", 60, "400.00", "100.00", true, false), // - period(2, "2026-03-02", "2026-03-19", 18, "400.00", "400.00", true, false), // - period(3, "2026-03-20", "2026-05-18", 60, "400.00", "400.00", null, true)); - - advanceBusinessDateWithCob(loanId, "2026-03-20", "2026-06-20"); - createBreachUndoReset(loanId); - - validateBreachSchedule(getBreachSchedule(loanId), // - period(1, "2026-01-01", "2026-03-01", 60, "400.00", "100.00", true, false), // - period(2, "2026-03-02", "2026-04-30", 60, "400.00", "400.00", true, false), // - period(3, "2026-05-01", "2026-06-29", 60, "400.00", "400.00", null, true)); - validateBreachPastDueAmount(getBreachPastDueAmount(loanId), "0"); - validateBreachActions(getBreachActions(loanId), // - action("RESET", "2026-05-10"), // - action("RESET", "2026-03-20"), // - action("UNDO_RESET", "2026-06-20")); - }); - } }