Skip to content

Commit 9233fed

Browse files
FINERACT-2455: WC breach/delinquency reschedule, re-date current period on frequency change + endDate validation
1 parent c8b48ee commit 9233fed

13 files changed

Lines changed: 546 additions & 160 deletions

fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature

Lines changed: 221 additions & 32 deletions
Large diffs are not rendered by default.

fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyReschedule.feature

Lines changed: 96 additions & 65 deletions
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.workingcapitalloan.domain;
20+
21+
import java.time.LocalDate;
22+
import java.time.temporal.ChronoUnit;
23+
import java.util.Comparator;
24+
import java.util.List;
25+
import java.util.Objects;
26+
27+
public final class WorkingCapitalLoanBreachPauseUtils {
28+
29+
private WorkingCapitalLoanBreachPauseUtils() {}
30+
31+
/**
32+
* Returns whether a new inclusive pause period shares at least one day with an existing one. Touching periods
33+
* (where one ends on the day the other starts) are treated as overlapping.
34+
*/
35+
public static boolean inclusivePausePeriodsOverlap(final LocalDate parsedPauseStart, final LocalDate parsedPauseEnd,
36+
final LocalDate existingPauseStart, final LocalDate existingPauseEnd) {
37+
if (parsedPauseStart == null || parsedPauseEnd == null || existingPauseStart == null || existingPauseEnd == null) {
38+
return false;
39+
}
40+
return !parsedPauseStart.isAfter(existingPauseEnd) && !parsedPauseEnd.isBefore(existingPauseStart);
41+
}
42+
43+
/**
44+
* A resumed pause effectively ends on the (inclusive) resume date, so a later pause may start the next day.
45+
*/
46+
public static LocalDate resolveEffectivePauseEnd(final WorkingCapitalLoanBreachAction pause,
47+
final List<WorkingCapitalLoanBreachAction> actions) {
48+
final LocalDate pauseStart = pause.getStartDate();
49+
final LocalDate pauseEnd = pause.getEndDate();
50+
if (pauseStart == null || pauseEnd == null) {
51+
return pauseEnd;
52+
}
53+
return actions.stream().filter(Objects::nonNull)
54+
.filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction()) && action.getStartDate() != null
55+
&& !action.getStartDate().isBefore(pauseStart) && !action.getStartDate().isAfter(pauseEnd))
56+
.map(WorkingCapitalLoanBreachAction::getStartDate).min(Comparator.naturalOrder()).orElse(pauseEnd);
57+
}
58+
59+
/**
60+
* Inclusive pause length: both start and end dates count as paused days.
61+
*/
62+
public static long calculatePauseExtensionDays(final LocalDate pauseStart, final LocalDate pauseEnd) {
63+
if (pauseStart == null || pauseEnd == null || pauseStart.isAfter(pauseEnd)) {
64+
return 0L;
65+
}
66+
return ChronoUnit.DAYS.between(pauseStart, pauseEnd) + 1;
67+
}
68+
69+
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBreachScheduleEvaluationUtils.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,14 @@ public static Optional<WorkingCapitalLoanBreachSchedule> resolveEvaluationPeriod
4141
.max(Comparator.comparingInt(period -> period.getPeriodNumber() != null ? period.getPeriodNumber() : Integer.MIN_VALUE));
4242
}
4343

44+
public static LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency,
45+
final WorkingCapitalLoanPeriodFrequencyType frequencyType) {
46+
return switch (frequencyType) {
47+
case DAYS -> fromDate.plusDays(frequency - 1);
48+
case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1);
49+
case MONTHS -> fromDate.plusMonths(frequency).minusDays(1);
50+
case YEARS -> fromDate.plusYears(frequency).minusDays(1);
51+
};
52+
}
53+
4454
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.workingcapitalloan.domain;
20+
21+
import java.time.LocalDate;
22+
import org.apache.fineract.portfolio.delinquency.domain.DelinquencyFrequencyType;
23+
24+
public final class WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils {
25+
26+
private WorkingCapitalLoanDelinquencyRangeScheduleEvaluationUtils() {}
27+
28+
public static LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency,
29+
final DelinquencyFrequencyType frequencyType) {
30+
return switch (frequencyType) {
31+
case DAYS -> fromDate.plusDays(frequency - 1);
32+
case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1);
33+
case MONTHS -> fromDate.plusMonths(frequency).minusDays(1);
34+
case YEARS -> fromDate.plusYears(frequency).minusDays(1);
35+
};
36+
}
37+
38+
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachActionWriteServiceImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public CommandProcessingResult createBreachAction(final Long workingCapitalLoanI
6262
|| WorkingCapitalLoanBreachActionType.RESUME.equals(breachAction.getAction())) {
6363
breachScheduleService.recalculatePeriodsForPauses(workingCapitalLoan);
6464
} else if (WorkingCapitalLoanBreachActionType.RESCHEDULE.equals(breachAction.getAction())) {
65-
breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan);
65+
breachScheduleService.rescheduleMinimumPayment(workingCapitalLoan, breachAction);
6666
} else if (WorkingCapitalLoanBreachActionType.RESET.equals(breachAction.getAction())) {
6767
breachResetService.resetBreach(workingCapitalLoan, saved);
6868
} else if (WorkingCapitalLoanBreachActionType.UNDO_RESET.equals(breachAction.getAction())) {

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.List;
2424
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachScheduleData;
2525
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
26+
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction;
2627
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule;
2728

2829
public interface WorkingCapitalLoanBreachScheduleService {
@@ -45,9 +46,11 @@ public interface WorkingCapitalLoanBreachScheduleService {
4546

4647
/**
4748
* Recalculates the schedule from the effective reschedule parameters resolved from the persisted RESCHEDULE
48-
* actions; a newly created reschedule action must therefore be saved before this is called.
49+
* actions; a newly created reschedule action must therefore be saved before this is called. When {@code action}
50+
* carries a frequency group, the current open period is also re-dated (toDate recalculated from its fromDate and
51+
* the new frequency, then extended by any overlapping pauses).
4952
*/
50-
void rescheduleMinimumPayment(WorkingCapitalLoan loan);
53+
void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanBreachAction action);
5154

5255
void recalculatePeriodsForPauses(WorkingCapitalLoan loan);
5356

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBreachScheduleServiceImpl.java

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance;
3939
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction;
4040
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType;
41+
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachPauseUtils;
4142
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachSchedule;
43+
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachScheduleEvaluationUtils;
4244
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType;
4345
import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException;
4446
import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanBreachScheduleMapper;
@@ -80,7 +82,8 @@ public void generateInitialPeriod(final WorkingCapitalLoan loan) {
8082

8183
final LocalDate fromDate = anchorDateOptional.get().plusDays(getBreachGraceDays(loan));
8284
final EffectiveBreachRescheduleParams params = resolveEffectiveRescheduleParams(loan.getId(), breachOpt.get());
83-
final LocalDate toDate = calculateToDate(fromDate, params.frequency(), params.frequencyType());
85+
final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, params.frequency(),
86+
params.frequencyType());
8487
final BigDecimal minPaymentAmount = calculateMinPaymentAmount(loan, params);
8588

8689
final WorkingCapitalLoanBreachSchedule period = createPeriod(loan, 1, fromDate, toDate, minPaymentAmount);
@@ -116,7 +119,8 @@ public void generateNextPeriodIfNeeded(final WorkingCapitalLoan loan, final Loca
116119
WorkingCapitalLoanBreachSchedule latestPeriod = latestPeriodOpt.get();
117120
while (!latestPeriod.getToDate().isAfter(businessDate)) {
118121
final LocalDate newFromDate = latestPeriod.getToDate().plusDays(1);
119-
final LocalDate newToDate = calculateToDate(newFromDate, effectiveFrequency, effectiveFreqType);
122+
final LocalDate newToDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(newFromDate, effectiveFrequency,
123+
effectiveFreqType);
120124

121125
final WorkingCapitalLoanBreachSchedule nextPeriod = createPeriod(loan, latestPeriod.getPeriodNumber() + 1, newFromDate,
122126
newToDate, minPaymentAmount);
@@ -213,7 +217,7 @@ public List<WorkingCapitalLoanBreachScheduleData> retrieveBreachSchedule(final L
213217
}
214218

215219
@Override
216-
public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) {
220+
public void rescheduleMinimumPayment(final WorkingCapitalLoan loan, final WorkingCapitalLoanBreachAction action) {
217221
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
218222
final Optional<WorkingCapitalBreach> breachOpt = getBreachConfig(loan);
219223
if (breachOpt.isEmpty()) {
@@ -224,6 +228,7 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) {
224228
final BigDecimal newMinPaymentAmount = calculateMinPaymentAmount(loan, params);
225229
final Integer newFrequency = params.frequency();
226230
final WorkingCapitalLoanPeriodFrequencyType newFreqType = params.frequencyType();
231+
final boolean frequencyChanged = action.getFrequency() != null;
227232

228233
final List<WorkingCapitalLoanBreachSchedule> periods = repository.findByLoanIdOrderByPeriodNumberAsc(loan.getId());
229234

@@ -240,6 +245,12 @@ public void rescheduleMinimumPayment(final WorkingCapitalLoan loan) {
240245
if (isCurrent) {
241246
currentPeriod = period;
242247
period.setBaseMinPaymentAmount(newMinPaymentAmount);
248+
if (frequencyChanged) {
249+
final LocalDate newToDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(period.getFromDate(),
250+
newFrequency, newFreqType);
251+
period.setToDate(newToDate);
252+
period.setNumberOfDays((int) ChronoUnit.DAYS.between(period.getFromDate(), newToDate) + 1);
253+
}
243254
period.setMinPaymentAmount(newMinPaymentAmount);
244255
period.setOutstandingAmount(newMinPaymentAmount.subtract(period.getPaidAmount()).max(BigDecimal.ZERO));
245256
period.setNearBreach(null);
@@ -278,7 +289,8 @@ public void recalculatePeriodsForPauses(final WorkingCapitalLoan loan) {
278289
LocalDate fromDate = periods.getFirst().getFromDate();
279290
for (final WorkingCapitalLoanBreachSchedule period : periods) {
280291
period.setFromDate(fromDate);
281-
period.setToDate(calculateToDate(fromDate, effectiveFrequency, effectiveFreqType));
292+
period.setToDate(
293+
WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, effectiveFrequency, effectiveFreqType));
282294
applyRecordedPauses(period, effectivePauses);
283295
recomputeBreach(period, businessDate);
284296
fromDate = period.getToDate().plusDays(1);
@@ -348,20 +360,11 @@ private void recomputeBreach(final WorkingCapitalLoanBreachSchedule period, fina
348360

349361
private List<EffectivePause> findEffectivePauses(final Long loanId) {
350362
final List<WorkingCapitalLoanBreachAction> actions = breachActionRepository.findByWorkingCapitalLoanIdOrderById(loanId);
351-
final List<WorkingCapitalLoanBreachAction> resumes = actions.stream()
352-
.filter(action -> WorkingCapitalLoanBreachActionType.RESUME.equals(action.getAction())).toList();
353363
return actions.stream().filter(action -> WorkingCapitalLoanBreachActionType.PAUSE.equals(action.getAction()))
354364
.sorted(Comparator.comparing(WorkingCapitalLoanBreachAction::getStartDate))
355-
.map(pause -> new EffectivePause(pause.getStartDate(), effectivePauseEnd(pause, resumes))).toList();
356-
}
357-
358-
private LocalDate effectivePauseEnd(final WorkingCapitalLoanBreachAction pause, final List<WorkingCapitalLoanBreachAction> resumes) {
359-
// Resume shortens the pause to end on the resume date. Pause start and end dates are inclusive, so the resume
360-
// date itself becomes the effective (inclusive) end and is still treated as a paused day.
361-
return resumes.stream()
362-
.filter(resume -> !pause.getStartDate().isAfter(resume.getStartDate())
363-
&& !resume.getStartDate().isAfter(pause.getEndDate()))
364-
.map(WorkingCapitalLoanBreachAction::getStartDate).min(Comparator.naturalOrder()).orElse(pause.getEndDate());
365+
.map(pause -> new EffectivePause(pause.getStartDate(),
366+
WorkingCapitalLoanBreachPauseUtils.resolveEffectivePauseEnd(pause, actions)))
367+
.toList();
365368
}
366369

367370
private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period, final List<EffectivePause> pauses) {
@@ -370,7 +373,7 @@ private void applyRecordedPauses(final WorkingCapitalLoanBreachSchedule period,
370373
final LocalDate pauseEnd = pause.endDate();
371374
// Apply only if the pause overlaps this period's date range
372375
if (!pauseEnd.isBefore(period.getFromDate()) && !pauseStart.isAfter(period.getToDate())) {
373-
final long pauseDays = ChronoUnit.DAYS.between(pauseStart, pauseEnd) + 1;
376+
final long pauseDays = WorkingCapitalLoanBreachPauseUtils.calculatePauseExtensionDays(pauseStart, pauseEnd);
374377
period.setToDate(period.getToDate().plusDays(pauseDays));
375378
if (period.getFromDate().isAfter(pauseStart)) {
376379
period.setFromDate(period.getFromDate().plusDays(pauseDays));
@@ -425,16 +428,6 @@ private Optional<LocalDate> resolveBreachAnchorDate(final WorkingCapitalLoan loa
425428
return loanRepository.findFirstActualDisbursementDate(loan.getId());
426429
}
427430

428-
private LocalDate calculateToDate(final LocalDate fromDate, final Integer frequency,
429-
final WorkingCapitalLoanPeriodFrequencyType frequencyType) {
430-
return switch (frequencyType) {
431-
case DAYS -> fromDate.plusDays(frequency - 1);
432-
case WEEKS -> fromDate.plusWeeks(frequency).minusDays(1);
433-
case MONTHS -> fromDate.plusMonths(frequency).minusDays(1);
434-
case YEARS -> fromDate.plusYears(frequency).minusDays(1);
435-
};
436-
}
437-
438431
private BigDecimal calculateMinPaymentAmount(final WorkingCapitalLoan loan, final EffectiveBreachRescheduleParams params) {
439432
final BigDecimal breachAmount = params.minimumPayment();
440433
if (breachAmount == null) {
@@ -494,7 +487,7 @@ private void updateFuturePeriods(final WorkingCapitalLoanBreachSchedule currentP
494487
LocalDate fromDate = currentPeriod.getToDate().plusDays(1);
495488

496489
for (final WorkingCapitalLoanBreachSchedule period : existingFuturePeriods) {
497-
final LocalDate toDate = calculateToDate(fromDate, frequency, frequencyType);
490+
final LocalDate toDate = WorkingCapitalLoanBreachScheduleEvaluationUtils.calculateToDate(fromDate, frequency, frequencyType);
498491
periodNumber++;
499492

500493
period.setPeriodNumber(periodNumber);

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyActionWriteServiceImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public CommandProcessingResult createDelinquencyAction(final Long workingCapital
8181
rangeScheduleService.reprocessDelinquencySchedule(workingCapitalLoan);
8282
}
8383
} else if (DelinquencyAction.RESCHEDULE.equals(action.getAction())) {
84-
rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan);
84+
rangeScheduleService.rescheduleMinimumPayment(workingCapitalLoan, action);
8585
rangeScheduleService.reprocessDelinquencySchedule(workingCapitalLoan);
8686
} else if (DelinquencyAction.RESUME.equals(action.getAction())) {
8787
final WorkingCapitalLoanDelinquencyAction activePause = validator.findActivePauseForResume(existing,

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleService.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ public interface WorkingCapitalLoanDelinquencyRangeScheduleService {
5454
/**
5555
* Re-derives the base expectation of the current period and the boundaries of future periods from the effective
5656
* reschedule parameters resolved from the persisted RESCHEDULE actions; a newly created reschedule action must
57-
* therefore be saved before this is called. Amounts, the remaining-balance cap and expired-period evaluation are
58-
* left to {@link #reprocessDelinquencySchedule(WorkingCapitalLoan)}, which the caller must invoke afterwards.
57+
* therefore be saved before this is called. When {@code action} carries a frequency group, the current open period
58+
* is also re-dated (toDate recalculated from its fromDate and the new frequency, then extended by any overlapping
59+
* pauses). Amounts, the remaining-balance cap and expired-period evaluation are left to
60+
* {@link #reprocessDelinquencySchedule(WorkingCapitalLoan)}, which the caller must invoke afterwards.
5961
*/
60-
void rescheduleMinimumPayment(WorkingCapitalLoan loan);
62+
void rescheduleMinimumPayment(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction action);
6163

6264
void resumeActivePause(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyAction activePause,
6365
WorkingCapitalLoanDelinquencyAction resumeAction);

0 commit comments

Comments
 (0)