Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ Feature: Working Capital Breach Disable
And Admin creates WC breach reset action
Then Working Capital loan breach schedule has the following data:
| periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach |
| 1 | 2026-01-01 | 2026-02-28 | 110.70 | 0 | null | null |
| 1 | 2026-01-01 | 2026-02-28 | 110.70 | 0 | null | false |
When Admin sets the business date to "20 January 2026"
And Admin runs inline COB job for Working Capital Loan by loanId
And Admin initiate a Working Capital loan breach disable with startDate "20 January 2026"
Expand All @@ -509,7 +509,7 @@ Feature: Working Capital Breach Disable
And Admin initiate a Working Capital loan breach enable with startDate "01 March 2026"
Then Working Capital loan breach schedule has the following data:
| periodNumber | fromDate | toDate | minPaymentAmount | outstandingAmount | nearBreach | breach |
| 1 | 2026-01-01 | 2026-02-28 | 110.70 | 110.70 | null | true |
| 1 | 2026-01-01 | 2026-02-28 | 110.70 | 0 | null | false |
| 2 | 2026-03-01 | 2026-04-30 | 110.70 | 110.70 | null | null |
When Admin closes the Working Capital loan with a full repayment on "01 March 2026"
Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET"
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,30 @@ public class BreachScheduleBusinessStep extends WorkingCapitalLoanCOBBusinessSte
private final WorkingCapitalLoanBreachScheduleService breachScheduleService;

@Override
public WorkingCapitalLoan execute(final WorkingCapitalLoan input) {
final boolean isDisbursed = input.getDisbursementDetails().stream()
public WorkingCapitalLoan execute(final WorkingCapitalLoan loan) {
final boolean isDisbursed = loan.getDisbursementDetails().stream()
.map(WorkingCapitalLoanDisbursementDetails::getActualDisbursementDate).anyMatch(Objects::nonNull);
if (!isDisbursed) {
log.debug("Skipping breach schedule for WC loan {} - not yet disbursed", input.getId());
return input;
log.debug("Skipping breach schedule for WC loan {} - not yet disbursed", loan.getId());
return loan;
}

final WorkingCapitalLoanProductRelatedDetails details = input.getLoanProductRelatedDetails();
final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails();
if (details == null || details.getBreach() == null) {
log.debug("Skipping breach schedule for WC loan {} - no breach configuration", input.getId());
return input;
log.debug("Skipping breach schedule for WC loan {} - no breach configuration", loan.getId());
return loan;
}

final LocalDate businessDate = DateUtils.getBusinessLocalDate();

if (!breachScheduleService.hasSchedule(input.getId())) {
breachScheduleService.generateInitialPeriod(input);
if (!breachScheduleService.hasSchedule(loan.getId())) {
breachScheduleService.generateInitialPeriod(loan);
}

breachScheduleService.generateNextPeriodIfNeeded(input, businessDate);
breachScheduleService.evaluateBreach(input, businessDate);
breachScheduleService.generateNextPeriodIfNeeded(loan, businessDate);
breachScheduleService.evaluateBreach(loan, businessDate);

return input;
return loan;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* 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.data;

import java.math.BigDecimal;
import java.time.LocalDate;

public record TransactionDateAndAmountHolder(LocalDate transactionDate, BigDecimal transactionAmount) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public class WorkingCapitalLoanBreachResetHistory extends AbstractAuditableWithU
@JoinColumn(name = "breach_schedule_id", nullable = false)
private WorkingCapitalLoanBreachSchedule breachSchedule;

@Column(name = "min_payment_amount", scale = 6, precision = 19)
private BigDecimal minPaymentAmount;

@Column(name = "outstanding_amount", scale = 6, precision = 19)
private BigDecimal outstandingAmount;

Expand All @@ -54,4 +57,12 @@ public class WorkingCapitalLoanBreachResetHistory extends AbstractAuditableWithU
@Column(name = "near_breach")
private Boolean nearBreach;

public WorkingCapitalLoanBreachResetHistory(WorkingCapitalLoanBreachAction resetAction, WorkingCapitalLoanBreachSchedule period) {
setBreachAction(resetAction);
setBreachSchedule(period);
setOutstandingAmount(period.getOutstandingAmount());
setBreach(period.getBreach());
setNearBreach(period.getNearBreach());
setMinPaymentAmount(period.getMinPaymentAmount());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,18 @@ public class WorkingCapitalLoanBreachSchedule extends AbstractAuditableWithUTCDa
@Column(name = "breach")
private Boolean breach;

@Column(name = "reset", nullable = false)
private boolean reset;

public void reset() {
this.reset = true;
this.breach = null;
this.nearBreach = null;
this.outstandingAmount = null;
this.paidAmount = null;
}

public BigDecimal getMinPaymentAmount() {
return reset ? null : this.minPaymentAmount;
Comment thread
adamsaghy marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,12 @@ select case when count(action) > 0 then true else false end from WorkingCapitalL
""")
boolean isBreachDisabledAsOf(@Param("loanId") Long loanId, @Param("date") LocalDate date);

@Query("""
SELECT ba FROM WorkingCapitalLoanBreachAction ba
WHERE ba.workingCapitalLoan.id= :workingCapitalLoanId
AND ba.action IN :actionTypes
ORDER BY ba.startDate, ba.id
""")
List<WorkingCapitalLoanBreachAction> findByLoanAndActionType(@Param("workingCapitalLoanId") Long workingCapitalLoanId,
@Param("actionTypes") List<WorkingCapitalLoanBreachActionType> actionTypes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.workingcapitalloan.data.TransactionDateAndAmountHolder;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand Down Expand Up @@ -79,14 +79,13 @@ BigDecimal sumDiscountFeeAdjustmentsAfter(@Param("wcLoanId") Long wcLoanId,
boolean existsByExternalId(ExternalId externalId);

@Query("""
select coalesce(sum(t.transactionAmount), 0)
from WorkingCapitalLoanTransaction t
where t.wcLoan.id = :wcLoanId and t.reversed = false
and t.transactionType in :transactionTypes
and t.transactionDate >= :fromDate and t.transactionDate <= :toDate
SELECT t.transactionDate, SUM(t.transactionAmount)
FROM WorkingCapitalLoanTransaction t
WHERE t.reversed = FALSE
AND t.wcLoan.id = :wcLoanId
AND t.transactionType in :transactionTypes
GROUP BY t.transactionDate
""")
BigDecimal sumBreachRelevantPaid(@Param("wcLoanId") Long wcLoanId,
@Param("transactionTypes") Collection<LoanTransactionType> transactionTypes, @Param("fromDate") LocalDate fromDate,
@Param("toDate") LocalDate toDate);

List<TransactionDateAndAmountHolder> fetchTransactionDateAndAmount(@Param("wcLoanId") Long wcLoanId,
@Param("transactionTypes") List<LoanTransactionType> transactionTypes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,10 @@
import java.time.LocalDate;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachAction;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBreachActionType;
import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachActionRepository;
Expand All @@ -39,71 +37,27 @@ public class WorkingCapitalLoanActiveBreachResetResolver {
private final WorkingCapitalLoanBreachActionRepository breachActionRepository;

public Optional<WorkingCapitalLoanBreachAction> findLatestActiveReset(final Long workingCapitalLoanId) {
return findLatestActiveReset(workingCapitalLoanId, null);
}

public Optional<LocalDate> findLatestActiveResetDate(final Long workingCapitalLoanId) {
return findLatestActiveReset(workingCapitalLoanId).map(WorkingCapitalLoanBreachAction::getStartDate);
}

public Optional<WorkingCapitalLoanBreachAction> findLatestActiveReset(final Long workingCapitalLoanId,
final Long maxActionIdExclusive) {
if (workingCapitalLoanId == null) {
return Optional.empty();
}
final List<WorkingCapitalLoanBreachAction> actions = loadActions(workingCapitalLoanId, maxActionIdExclusive);
final Optional<Long> latestActiveResetId = findLatestActiveResetId(actions);
return latestActiveResetId
.flatMap(activeResetId -> actions.stream().filter(action -> Objects.equals(action.getId(), activeResetId)).findFirst());
List<WorkingCapitalLoanBreachAction> filtereredList = breachActionRepository.findByLoanAndActionType(workingCapitalLoanId,
List.of(WorkingCapitalLoanBreachActionType.RESET, WorkingCapitalLoanBreachActionType.UNDO_RESET));
Deque<WorkingCapitalLoanBreachAction> queue = new ArrayDeque<>();
filtereredList.forEach(action -> {
if (action.getAction().equals(WorkingCapitalLoanBreachActionType.RESET)) {
queue.push(action);
} else if (action.getAction().equals(WorkingCapitalLoanBreachActionType.UNDO_RESET)) {
if (!queue.isEmpty()) {
queue.pop();
}
}
});
return queue.isEmpty() ? Optional.empty() : Optional.of(queue.peek());
}

public boolean hasActiveReset(final Long workingCapitalLoanId) {
return findLatestActiveReset(workingCapitalLoanId).isPresent();
}

public boolean existsActiveResetInPeriod(final Long workingCapitalLoanId, final LocalDate fromDate, final LocalDate toDate) {
if (workingCapitalLoanId == null || fromDate == null || toDate == null) {
return false;
}
final List<WorkingCapitalLoanBreachAction> actions = loadActions(workingCapitalLoanId, null);
final Set<Long> activeResetIds = findActiveResetIds(actions);
return actions.stream().filter(action -> WorkingCapitalLoanBreachActionType.RESET.equals(action.getAction()))
.filter(action -> action.getId() != null && activeResetIds.contains(action.getId()))
.anyMatch(action -> isDateWithinPeriod(action.getStartDate(), fromDate, toDate));
}

private List<WorkingCapitalLoanBreachAction> loadActions(final Long workingCapitalLoanId, final Long maxActionIdExclusive) {
return breachActionRepository.findByWorkingCapitalLoanIdOrderById(workingCapitalLoanId).stream()
.filter(action -> action.getId() != null && (maxActionIdExclusive == null || action.getId() < maxActionIdExclusive))
.toList();
}

private Optional<Long> findLatestActiveResetId(final List<WorkingCapitalLoanBreachAction> actions) {
final Deque<Long> activeResetIds = buildActiveResetStack(actions);
return activeResetIds.isEmpty() ? Optional.empty() : Optional.of(activeResetIds.peekLast());
return findLatestActiveReset(workingCapitalLoanId).filter(a -> DateUtils.isDateInRangeInclusive(a.getStartDate(), fromDate, toDate))
.isPresent();
}

private Set<Long> findActiveResetIds(final List<WorkingCapitalLoanBreachAction> actions) {
return new HashSet<>(buildActiveResetStack(actions));
}

private Deque<Long> buildActiveResetStack(final List<WorkingCapitalLoanBreachAction> actions) {
final Deque<Long> activeResetIds = new ArrayDeque<>();
for (final WorkingCapitalLoanBreachAction action : actions) {
if (action == null || action.getId() == null) {
continue;
}
if (WorkingCapitalLoanBreachActionType.RESET.equals(action.getAction())) {
activeResetIds.addLast(action.getId());
} else if (WorkingCapitalLoanBreachActionType.UNDO_RESET.equals(action.getAction()) && !activeResetIds.isEmpty()) {
activeResetIds.removeLast();
}
}
return activeResetIds;
}

private boolean isDateWithinPeriod(final LocalDate date, final LocalDate fromDate, final LocalDate toDate) {
return date != null && !date.isBefore(fromDate) && !date.isAfter(toDate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public CommandProcessingResult createBreachAction(final Long workingCapitalLoanI
.reduce((first, second) -> second).ifPresent(action -> {
action.setEndDate(breachAction.getStartDate().minusDays(1));
});
breachScheduleService.reEvaluateAfterEnable(workingCapitalLoan, breachAction.getStartDate());
breachScheduleService.reprocessBreachSchedule(workingCapitalLoan);
}

return new CommandProcessingResultBuilder() //
Expand Down
Loading
Loading