Skip to content
Draft
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 @@ -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 @@ -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 @@ -115,6 +117,33 @@ 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;
}

final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("workingCapitalLoanCharge");

baseDataValidator.reset().parameter(WorkingCapitalLoanChargeConstants.dueDateParamName).value(dueDate).notNull();
if (dueDate != null && dueDate.isBefore(businessDate)) {
baseDataValidator.reset().parameter(WorkingCapitalLoanChargeConstants.dueDateParamName).value(dueDate)
.failWithCode("cannot.be.in.the.past", "DueDate cannot be in the past");
}
if (!(loanStatus.isActive() || loanStatus.isClosedObligationsMet() || loanStatus.isOverpaid())) {
baseDataValidator.reset().parameter("workingCapitalLoan").value(loanStatus).failWithCode("loan.should.be.active",
"Loan must be active, closed (obligations met) or overpaid to add this charge");
}

throwExceptionIfValidationWarningsExist(dataValidationErrors);
}

private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,26 @@

import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
Expand All @@ -47,9 +52,14 @@
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor;
import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel;
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan;
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation;
Expand Down Expand Up @@ -88,18 +98,48 @@ public class WorkingCapitalLoanChargeWritePlatformServiceImpl implements Working
private final WorkingCapitalLoanAccountingProcessor accountingProcessor;
private final WorkingCapitalLoanChargePaymentHandler chargePaymentHandler;
private final WorkingCapitalLoanBalanceUpdater balanceUpdater;
private final WorkingCapitalLoanLifecycleStateMachine stateMachine;
private final WorkingCapitalLoanAllocationRequestFactory allocationRequestFactory;
private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor;
private final WorkingCapitalLoanDelinquencyRangeScheduleService delinquencyRangeScheduleService;
private final WorkingCapitalLoanBreachScheduleService breachScheduleService;
private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper;

@Transactional
@Override
public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command) {
loanChargeDataValidator.validateCreateLoanCharge(command.json());
WorkingCapitalLoan loan = workingCapitalLoanRepository.findById(loanId)
.orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId));

final LoanStatus statusBeforeCharge = loan.getLoanStatus();

WorkingCapitalLoanCharge loanCharge = assemblyChargeFromCommand(loan, command);

loanCharge = loanChargeRepository.saveAndFlush(loanCharge);

addChargeToBalance(loan, loanCharge);
final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId())
.orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan));
addChargeToBalance(balance, loanCharge);

// A specified-due-date charge added after the loan matured (closed / overpaid / active-past-maturity) creates a
// new outstanding obligation: consume any overpayment against it, then let the resulting balance drive the
// status (reopen / stay overpaid / close), the new maturity date and the delinquency / breach schedules.
final LocalDate chargeDueDate = loanCharge.getDueDate();
if (requiresChargeDrivenLifecycle(loan, statusBeforeCharge, chargeDueDate)) {
if (statusBeforeCharge.isOverpaid()) {
consumeOverpaymentAgainstCharges(loan, balance);
}
applyChargeDrivenLifecycle(loan, balance, statusBeforeCharge, chargeDueDate);
}

balanceRepository.saveAndFlush(balance);
workingCapitalLoanRepository.saveAndFlush(loan);

final Map<String, Object> changes = new LinkedHashMap<>();
if (loan.getLoanStatus() != statusBeforeCharge) {
changes.put("status", loan.getLoanStatus());
}

return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
Expand All @@ -108,9 +148,91 @@ public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withLoanId(loanId) //
.with(changes) //
.build();
}

private boolean requiresChargeDrivenLifecycle(final WorkingCapitalLoan loan, final LoanStatus statusBeforeCharge,
final LocalDate chargeDueDate) {
if (chargeDueDate == null) {
return false;
}
if (statusBeforeCharge.isClosedObligationsMet() || statusBeforeCharge.isOverpaid()) {
return true;
}
if (statusBeforeCharge.isActive()) {
final LocalDate scheduledMaturityDate = resolveScheduledMaturityDate(loan);
return scheduledMaturityDate != null && chargeDueDate.isAfter(scheduledMaturityDate);
}
return false;
}

private LocalDate resolveScheduledMaturityDate(final WorkingCapitalLoan loan) {
final MathContext mc = MoneyHelper.getMathContext();
return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan))
.map(ProjectedAmortizationScheduleModel::scheduledMaturityDate).orElse(null);
}

private void consumeOverpaymentAgainstCharges(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance) {
final BigDecimal availableOverpayment = MathUtil.nullToZero(balance.getOverpaymentAmount());
if (!MathUtil.isGreaterThanZero(availableOverpayment)) {
return;
}
final List<WorkingCapitalLoanCharge> charges = loanChargeRepository.findByLoanIdAndActiveTrueOrderByDueDateAscIdAsc(loan.getId());
final LocalDate businessDate = ThreadLocalContextUtil.getBusinessDate();
final WorkingCapitalLoanAllocationRequest request = allocationRequestFactory.build(loan, balance, charges, businessDate,
availableOverpayment);
final WorkingCapitalLoanAllocationPlan plan = allocationProcessor.plan(request);

final Map<Long, WorkingCapitalLoanCharge> chargesById = charges.stream()
.collect(Collectors.toMap(WorkingCapitalLoanCharge::getId, Function.identity()));
for (final WorkingCapitalLoanAllocationPlan.ChargeAllocation chargeAllocation : plan.chargeAllocations()) {
final WorkingCapitalLoanCharge charge = chargesById.get(chargeAllocation.chargeId());
if (charge != null) {
chargePaymentHandler.applyChargePayment(charge, chargeAllocation.amount());
}
}
loanChargeRepository.saveAll(charges);

balance.setOverpaymentAmount(BigDecimal.ZERO);
balanceUpdater.apply(balance, plan);
}

private void applyChargeDrivenLifecycle(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance,
final LoanStatus statusBeforeCharge, final LocalDate chargeDueDate) {
final BigDecimal overpayment = MathUtil.nullToZero(balance.getOverpaymentAmount());
if (MathUtil.isGreaterThanZero(overpayment)) {
// The overpayment still exceeds the charges: the loan stays overpaid, nothing becomes outstanding.
return;
}

if (MathUtil.isGreaterThanZero(balance.getTotalOutstanding())) {
// An outstanding obligation appeared: a closed / overpaid loan reopens; an active loan stays active.
if (statusBeforeCharge.isClosedObligationsMet() || statusBeforeCharge.isOverpaid()) {
stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan);
}
loan.setMaturedOnDate(chargeDueDate);
generateDelinquencyAndBreachPeriods(loan, chargeDueDate);
} else if (statusBeforeCharge.isOverpaid()) {
// The overpayment exactly settled the charge: the loan closes with obligations met.
stateMachine.transition(WorkingCapitalLoanEvent.LOAN_CREDIT_BALANCE_REFUND_IN_FULL, loan);
loan.setMaturedOnDate(chargeDueDate);
}
}

private void generateDelinquencyAndBreachPeriods(final WorkingCapitalLoan loan, final LocalDate chargeDueDate) {
if (delinquencyRangeScheduleService.hasSchedule(loan.getId())) {
delinquencyRangeScheduleService.generateNextPeriodIfNeeded(loan, chargeDueDate);
} else {
delinquencyRangeScheduleService.generateInitialPeriod(loan);
}
if (breachScheduleService.hasSchedule(loan.getId())) {
breachScheduleService.generateNextPeriodIfNeeded(loan, chargeDueDate);
} else {
breachScheduleService.generateInitialPeriod(loan);
}
}

@Transactional
@Override
public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final Long wcLoanChargeId, final JsonCommand command) {
Expand Down Expand Up @@ -252,28 +374,13 @@ private WorkingCapitalLoanCharge assemblyChargeFromCommand(WorkingCapitalLoan lo
final ExternalId externalId = externalIdFactory.createFromCommand(command, WorkingCapitalLoanConstants.externalIdParameterName);

final Charge chargeDefinition = chargeRepository.findOneWithNotFoundDetection(chargeId);
if (ChargeTimeType.SPECIFIED_DUE_DATE.getValue().equals(chargeDefinition.getChargeTimeType())) {
if (dueDate == null) {
throw new PlatformApiDataValidationException("field.is.mandatory", "Field is mandatory",
WorkingCapitalLoanChargeConstants.dueDateParamName);
}
if (dueDate.isBefore(ThreadLocalContextUtil.getBusinessDate())) {
throw new PlatformApiDataValidationException("dueDate.cannot.be.in.the.past", "DueDate cannot be in the past",
WorkingCapitalLoanChargeConstants.dueDateParamName);
}
if (!loan.getLoanStatus().isActive()) {
throw new PlatformApiDataValidationException("loan.should.be.active", "Loan should be in active status",
"workingCapitalLoan");
}
}
loanChargeDataValidator.validateCreateLoanChargeAgainstLoan(loan.getLoanStatus(),
ChargeTimeType.fromInt(chargeDefinition.getChargeTimeType()), dueDate, ThreadLocalContextUtil.getBusinessDate());
return WorkingCapitalLoanCharge.build(loan, externalId, chargeDefinition, amount, dueDate,
ThreadLocalContextUtil.getBusinessDate());
}

private void addChargeToBalance(WorkingCapitalLoan loan, WorkingCapitalLoanCharge loanCharge) {
final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId())
.orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan));

private void addChargeToBalance(final WorkingCapitalLoanBalance balance, final WorkingCapitalLoanCharge loanCharge) {
if (loanCharge.isPenaltyCharge()) {
balance.setPenalty(balance.getPenalty().add(loanCharge.getAmount()));
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -844,20 +844,20 @@ private void handleStateChanges(WorkingCapitalLoan loan, LocalDate transactionDa
if (loan.getBalance() != null) {
final BigDecimal overpaymentAmount = loan.getBalance().getOverpaymentAmount() != null ? loan.getBalance().getOverpaymentAmount()
: BigDecimal.ZERO;
final BigDecimal principalOutstanding = loan.getBalance().getPrincipalOutstanding() != null
? loan.getBalance().getPrincipalOutstanding()
: BigDecimal.ZERO;
// A loan is fully repaid only when nothing (principal, fee or penalty) remains outstanding: a charge that
// leaves a fee/penalty due must keep the loan open even once the principal is settled.
final BigDecimal totalOutstanding = loan.getBalance().getTotalOutstanding();
if (overpaymentAmount.compareTo(BigDecimal.ZERO) > 0) {
this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan);
if (loan.getMaturedOnDate() == null) {
loan.setMaturedOnDate(transactionDate);
}
} else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) {
} else if (totalOutstanding.compareTo(BigDecimal.ZERO) == 0) {
this.stateMachine.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 (loan.getMaturedOnDate() != null && this.stateMachine.canTransition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan)) {
this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan);
loan.setMaturedOnDate(null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRateRequest;
import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData;
import org.apache.fineract.client.models.WorkingCapitalLoanChargeData;
import org.apache.fineract.client.models.WorkingCapitalLoanDelinquencyRangeScheduleData;
import org.apache.fineract.client.models.WorkingCapitalLoanPeriodPaymentRateChangeData;

public class FeignWorkingCapitalLoanHelper {
Expand Down Expand Up @@ -159,6 +160,10 @@ public List<WorkingCapitalLoanBreachScheduleData> getBreachSchedule(Long loanId)
return ok(() -> fineractClient.workingCapitalLoanBreachSchedule().retrieveBreachSchedule(loanId));
}

public List<WorkingCapitalLoanDelinquencyRangeScheduleData> getDelinquencyRangeSchedule(Long loanId) {
return ok(() -> fineractClient.workingCapitalLoanDelinquencyRangeSchedule().retrieveDelinquencyRangeSchedule(loanId));
}

public void adjustCharge(Long loanId, Long loanChargeId, PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request) {
ok(() -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "adjustment"));
}
Expand Down
Loading
Loading