Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,4 @@ Optional<WorkingCapitalLoanBreachSchedule> findByLoanIdAndFromDateLessThanEqualA
AND s.toDate >= :businessDate""")
Optional<WorkingCapitalLoanBreachSchedule> 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<WorkingCapitalLoanBreachSchedule> findFuturePeriodsOrderByPeriodNumberAsc(@Param("loanId") Long loanId,
@Param("businessDate") LocalDate businessDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<WorkingCapitalLoanBreachAction> 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<WorkingCapitalLoanBreachAction> activeResets(final List<WorkingCapitalLoanBreachAction> actions) {
final Deque<WorkingCapitalLoanBreachAction> 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()) {
Expand All @@ -58,6 +70,17 @@ public Deque<WorkingCapitalLoanBreachAction> activeResets(final List<WorkingCapi
return stack;
}

/**
* The recorded actions on their own timeline, earliest first, with the nulls a caller may hold dropped. Every
* replay of the actions goes through this order, not only the reset and undo pair this resolver owns.
*/
public static List<WorkingCapitalLoanBreachAction> chronological(final List<WorkingCapitalLoanBreachAction> actions) {
if (actions == null) {
return List.of();
}
return actions.stream().filter(Objects::nonNull).sorted(CHRONOLOGICAL).toList();
}

public Deque<WorkingCapitalLoanBreachAction> activeResets(final Long workingCapitalLoanId) {
return activeResets(breachActionRepository.findByWorkingCapitalLoanIdOrderById(workingCapitalLoanId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading