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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ Feature: Working Capital Loan Goodwill Credit Backdated and Undo
| 10 January 2026 | Repayment | 9000.0 | 8500.0 | 0.0 | 0.0 | false |

@TestRailId:C85544
Scenario: Verify Working Capital Goodwill Credit backdated/undo - UC13: Goodwill credit settling only a fee on a closed loan keeps the loan closed
Scenario: Verify Working Capital Goodwill Credit backdated/undo - UC13: Goodwill credit settling the remaining due fee closes the loan
When Admin sets the business date to "01 January 2026"
And Admin creates a client with random data
And Admin creates a new Working Capital Loan Product with payment allocation order:
Expand All @@ -349,7 +349,7 @@ Feature: Working Capital Loan Goodwill Credit Backdated and Undo
When Admin sets the business date to "05 January 2026"
And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "05 January 2026" due date and 35.0 transaction amount
And Customer makes repayment on "05 January 2026" with 9000 transaction amount on Working Capital loan
Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET"
Then Working Capital loan status will be "ACTIVE"
And Working Capital Loan charge balances has the following data:
| Fee Amount | Fee Paid | Fee Outstanding |
| 35.0 | 0.0 | 35.0 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ public List<ProjectedPayment> projectedPayments() {
return projectedPayments;
}

/**
* The scheduled maturity is the date of the last real projected payment (a period with {@code paymentNo > 0}, i.e.
* excluding the disbursement row). Returns {@code null} when the schedule has no real payments.
*/
public LocalDate scheduledMaturityDate() {
if (projectedPayments == null) {
return null;
}
return projectedPayments.stream().filter(payment -> payment.paymentNo() > 0).map(ProjectedPayment::date).filter(Objects::nonNull)
.max(Comparator.naturalOrder()).orElse(null);
}

public List<ProjectedPayment> originalProjectedPayments() {
return originalProjectedPayments;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
Expand All @@ -43,37 +44,60 @@ public boolean canTransition(final WorkingCapitalLoanEvent event, final WorkingC
}

/**
* Determines and applies the status transition implied by the loan's current balance after a monetary transaction:
* Determines and applies the status transition implied by the loan's balance after a monetary transaction:
* overpaid, repaid in full, or reopened from a matured state. Mirrors
* {@code DefaultLoanLifecycleStateMachine.determineAndTransition} in the core loan module.
* <p>
* Closure is decided on the amount that is <em>due</em> as of {@code transactionDate}: the outstanding principal
* plus any fee/penalty charge whose due date has arrived. A not-yet-due (in advance) charge does not block closure,
* while a due-but-unpaid fee/penalty keeps the loan open even once the principal is settled.
*/
public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDate transactionDate) {
public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDate transactionDate,
final List<WorkingCapitalLoanCharge> activeCharges) {
if (loan.getBalance() == null) {
return;
}
final LoanStatus currentStatus = loan.getLoanStatus();
final BigDecimal overpaymentAmount = MathUtil.nullToZero(loan.getBalance().getOverpaymentAmount());
final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding());
final BigDecimal dueOutstanding = dueOutstanding(loan, transactionDate, activeCharges);
if (overpaymentAmount.compareTo(BigDecimal.ZERO) > 0) {
if (currentStatus == null || !currentStatus.isOverpaid()) {
transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan);
}
if (loan.getMaturedOnDate() == null) {
loan.setMaturedOnDate(transactionDate);
}
} else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) {
} else if (dueOutstanding.compareTo(BigDecimal.ZERO) == 0) {
if (currentStatus == null || !currentStatus.isClosedObligationsMet()) {
transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan);
}
if (loan.getMaturedOnDate() == null) {
loan.setMaturedOnDate(transactionDate);
}
} else if (principalOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null) {
} else if (dueOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null
&& canTransition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan)) {
transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan);
loan.setMaturedOnDate(null);
}
}

/**
* Outstanding amount that is due as of {@code asOf}: principal (always due for closure) plus every active
* fee/penalty charge whose due date is on or before {@code asOf}. In-advance charges are excluded so they do not
* keep an otherwise settled loan open.
*/
private BigDecimal dueOutstanding(final WorkingCapitalLoan loan, final LocalDate asOf,
final List<WorkingCapitalLoanCharge> activeCharges) {
final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding());
if (activeCharges == null || activeCharges.isEmpty()) {
return principalOutstanding;
}
final BigDecimal dueChargeOutstanding = activeCharges.stream()
.filter(charge -> charge.getDueDate() != null && !charge.getDueDate().isAfter(asOf))
.map(charge -> MathUtil.nullToZero(charge.getAmountOutstanding())).reduce(BigDecimal.ZERO, BigDecimal::add);
return principalOutstanding.add(dueChargeOutstanding);
}

private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) {
LoanStatus from = loan.getLoanStatus();
if (from == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.springframework.stereotype.Service;

@Service
Expand Down Expand Up @@ -108,6 +110,29 @@ public void validateCreateLoanCharge(final String json) {

}

/**
* Validates a specified-due-date charge against the target loan: the due date is mandatory, cannot fall in the past
* and the loan must be active, closed (obligations met) or overpaid so a charge can be raised after the loan
* matured. Non specified-due-date charges carry no due date and are unaffected.
*/
public void validateCreateLoanChargeAgainstLoan(final LoanStatus loanStatus, final ChargeTimeType chargeTimeType,
final LocalDate dueDate, final LocalDate businessDate) {
if (chargeTimeType != ChargeTimeType.SPECIFIED_DUE_DATE) {
return;
}
if (dueDate == null) {
throw new PlatformApiDataValidationException("field.is.mandatory", "Field is mandatory",
WorkingCapitalLoanChargeConstants.dueDateParamName);
}
if (dueDate.isBefore(businessDate)) {
throw new PlatformApiDataValidationException("dueDate.cannot.be.in.the.past", "DueDate cannot be in the past",
WorkingCapitalLoanChargeConstants.dueDateParamName);
}
if (!(loanStatus.isActive() || loanStatus.isClosedObligationsMet() || loanStatus.isOverpaid())) {
throw new PlatformApiDataValidationException("loan.should.be.active", "Loan should be in active status", "workingCapitalLoan");
}
}

private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
Expand Down
Loading
Loading