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 @@ -1878,7 +1878,10 @@ public void verifyTemplateAdvancedPaymentAllocationTypes(final DataTable table)
public void createWorkingCapitalLoanProductWithPaymentAllocationOrder(final DataTable table) {
final List<String> rules = table.asList();
final String productName = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10);
final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest() //
// Allow attribute overrides so loans created from this product can supply their own discount (the loan
// creation step always sends a discount value).
final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory
.defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest() //
.name(productName) //
.paymentAllocation(List.of(WorkingCapitalRequestFactory
.createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), rules)));
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public WorkingCapitalLoanChargeData toData() {
}

public BigDecimal getAmountOutstanding() {
return getAmount().min(getAmountPaid());
return getAmount().subtract(getAmountPaid() != null ? getAmountPaid() : BigDecimal.ZERO).max(BigDecimal.ZERO);
}

public static WorkingCapitalLoanCharge build(WorkingCapitalLoan loan, ExternalId externalId, Charge charge, BigDecimal amount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ public static WorkingCapitalLoanTransactionAllocation forPrincipalAllocation(fin
return allocation;
}

public static WorkingCapitalLoanTransactionAllocation forPortions(final WorkingCapitalLoanTransaction transaction,
final BigDecimal principalAmount, final BigDecimal feeAmount, final BigDecimal penaltyAmount) {
final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation();
allocation.wcLoanTransaction = transaction;
allocation.principalPortion = MathUtil.nullToZero(principalAmount);
allocation.feeChargesPortion = MathUtil.nullToZero(feeAmount);
allocation.penaltyChargesPortion = MathUtil.nullToZero(penaltyAmount);
return allocation;
}

public static WorkingCapitalLoanTransactionAllocation forDisbursementDiscount(final WorkingCapitalLoanTransaction transaction,
final BigDecimal principalAmount) {
final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public interface WorkingCapitalLoanChargeRepository

Long findIdByExternalId(ExternalId externalId);

List<WorkingCapitalLoanCharge> findByLoan_IdAndActiveTrueOrderByDueDateAscIdAsc(Long loanId);

@Query("select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData("
+ "lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol,"
+ " oc.nameCode, lc.amount, lc.amountPaid, lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) from WorkingCapitalLoanCharge lc join fetch lc.charge c join OrganisationCurrency oc on c.currencyCode = oc.code join fetch lc.loan l where l.id = :loanId and lc.id = :id")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
*/
package org.apache.fineract.portfolio.workingcapitalloan.repository;

import java.util.Collection;
import java.util.List;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation;
import org.springframework.data.jpa.repository.JpaRepository;

public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository<WorkingCapitalLoanTransactionAllocation, Long> {}
public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository<WorkingCapitalLoanTransactionAllocation, Long> {

List<WorkingCapitalLoanTransactionAllocation> findByWcLoanTransaction_IdIn(Collection<Long> transactionIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;

public interface WorkingCapitalLoanAmortizationScheduleWriteService {

/** A principal repayment applied to the amortization schedule on a given date. */
record PrincipalPayment(LocalDate date, BigDecimal amount) {
}

void generateAndSaveAmortizationSchedule(Long loanId, ProjectedAmortizationScheduleGenerateRequest request);

void generateAndSaveAmortizationScheduleOnDisbursement(WorkingCapitalLoan loan, BigDecimal disbursedAmount, LocalDate disbursementDate);
Expand All @@ -44,4 +49,11 @@ public interface WorkingCapitalLoanAmortizationScheduleWriteService {
* disbursement generation) and re-applies recorded actual repayments only.
*/
void applyDiscountFeeAdjustment(WorkingCapitalLoan loan);

/**
* Rebuilds the projected schedule from scratch (as on disbursement) and re-applies the given principal payments in
* chronological order. Used by transaction reprocessing, where re-allocation can change the principal portion
* recorded on each transaction date.
*/
void rebuildScheduleFromPrincipalPayments(WorkingCapitalLoan loan, List<PrincipalPayment> principalPayments);
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,8 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) {

final List<ProjectedAmortizationScheduleModel.ActualPayment> preservedPayments = currentModel.snapshotActualPayments();

final BigDecimal disbursedAmount = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()
&& loan.getDisbursementDetails().getFirst().getActualAmount() != null
? loan.getDisbursementDetails().getFirst().getActualAmount()
: BigDecimal.ZERO;
final LocalDate disbursementDate = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()
? loan.getDisbursementDetails().getFirst().getActualDisbursementDate()
: null;
final BigDecimal disbursedAmount = resolveActualDisbursedAmount(loan);
final LocalDate disbursementDate = resolveActualDisbursementDate(loan);

final ProjectedAmortizationScheduleModel restatedModel = generateProjectedAmortizationScheduleModel(loan, disbursedAmount,
disbursementDate);
Expand All @@ -232,6 +227,23 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) {
scheduleRepositoryWrapper.writeModel(loan, restatedModel);
}

@Override
public void rebuildScheduleFromPrincipalPayments(final WorkingCapitalLoan loan, final List<PrincipalPayment> principalPayments) {
Validate.notNull(loan, "loan must not be null");
Validate.notNull(principalPayments, "principalPayments must not be null");

final BigDecimal disbursedAmount = resolveActualDisbursedAmount(loan);
final LocalDate disbursementDate = resolveActualDisbursementDate(loan);

final ProjectedAmortizationScheduleModel model = generateProjectedAmortizationScheduleModel(loan, disbursedAmount,
disbursementDate);
principalPayments.stream().filter(payment -> payment.amount() != null && payment.amount().signum() > 0)
.sorted(Comparator.comparing(PrincipalPayment::date))
.forEach(payment -> model.applyPayment(payment.date(), payment.amount()));

scheduleRepositoryWrapper.writeModel(loan, model);
}

private LocalDate resolveLoanDisbursementDate(final WorkingCapitalLoan loan) {
if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()) {
final LocalDate actualDate = loan.getDisbursementDetails().getFirst().getActualDisbursementDate();
Expand All @@ -241,4 +253,19 @@ private LocalDate resolveLoanDisbursementDate(final WorkingCapitalLoan loan) {
}
throw new IllegalStateException("Active loan " + loan.getId() + " has no actual disbursement date");
}

private BigDecimal resolveActualDisbursedAmount(final WorkingCapitalLoan loan) {
if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()
&& loan.getDisbursementDetails().getFirst().getActualAmount() != null) {
return loan.getDisbursementDetails().getFirst().getActualAmount();
}
return BigDecimal.ZERO;
}

private LocalDate resolveActualDisbursementDate(final WorkingCapitalLoan loan) {
if (loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty()) {
return loan.getDisbursementDetails().getFirst().getActualDisbursementDate();
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public void applyRepayment(Long loanId, LocalDate transactionDate, BigDecimal am
.findByLoanIdAndFromDateLessThanEqualAndToDateGreaterThanEqual(loanId, transactionDate, transactionDate);
BigDecimal transactionAmount = amount;
for (WorkingCapitalLoanDelinquencyRangeSchedule period : pastOpenPeriods) {
BigDecimal payAmount = MathUtil.min(amount, period.getOutstandingAmount(), true);
BigDecimal payAmount = MathUtil.min(transactionAmount, period.getOutstandingAmount(), true);
transactionAmount = transactionAmount.subtract(payAmount);
period.setPaidAmount(period.getPaidAmount().add(payAmount));
period.setOutstandingAmount(period.getOutstandingAmount().subtract(payAmount));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* 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.service;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.loanproduct.domain.DueType;
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType;
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.WorkingCapitalLoanPaymentAllocationRule;
import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType;
import org.springframework.stereotype.Component;

/**
* Allocates a repayment-like amount across penalty charges, fee charges and principal following the payment allocation
* order configured on the loan (copied from the product at creation). The same allocation is used by the forward
* repayment flow and by {@link WorkingCapitalLoanTransactionReprocessingService} when transactions are replayed in a
* changed chronological order.
*
* <p>
* The processor mutates the passed {@link WorkingCapitalLoanCharge charges} (amount paid / paid flag) and
* {@link WorkingCapitalLoanBalance balance} (principal/fee/penalty paid, overpayment) in place; persistence is the
* caller's responsibility. When the loan has no configured allocation order the amount falls back to principal-only,
* preserving the original behaviour for products that do not use charge allocation.
*/
@Component
public class WorkingCapitalLoanPaymentAllocationProcessor {

/**
* @param charges
* active charges of the loan, ordered oldest due date first (so the oldest outstanding charge is settled
* first within a bucket)
*/
public AllocationResult allocate(final WorkingCapitalLoan loan, final WorkingCapitalLoanBalance balance,
final List<WorkingCapitalLoanCharge> charges, final LocalDate transactionDate, final BigDecimal amount) {
BigDecimal remaining = MathUtil.nullToZero(amount);
BigDecimal principalPortion = BigDecimal.ZERO;
BigDecimal feePortion = BigDecimal.ZERO;
BigDecimal penaltyPortion = BigDecimal.ZERO;

for (final WorkingCapitalPaymentAllocationType allocationType : resolveAllocationOrder(loan)) {
if (!MathUtil.isGreaterThanZero(remaining)) {
break;
}
final DueType dueType = allocationType.getDueType();
switch (allocationType.getAllocationType()) {
case PRINCIPAL -> {
final BigDecimal applied = allocateToPrincipal(balance, remaining);
principalPortion = principalPortion.add(applied);
remaining = remaining.subtract(applied);
}
case FEE -> {
final BigDecimal applied = allocateToCharges(charges, false, dueType, transactionDate, remaining, balance);
feePortion = feePortion.add(applied);
remaining = remaining.subtract(applied);
}
case PENALTY -> {
final BigDecimal applied = allocateToCharges(charges, true, dueType, transactionDate, remaining, balance);
penaltyPortion = penaltyPortion.add(applied);
remaining = remaining.subtract(applied);
}
default -> {
}
}
}

final BigDecimal overpayment = remaining.max(BigDecimal.ZERO);
balance.setOverpaymentAmount(MathUtil.nullToZero(balance.getOverpaymentAmount()).add(overpayment));
return new AllocationResult(principalPortion, feePortion, penaltyPortion, overpayment);
}

private BigDecimal allocateToPrincipal(final WorkingCapitalLoanBalance balance, final BigDecimal remaining) {
final BigDecimal outstanding = MathUtil.nullToZero(balance.getPrincipalOutstanding());
final BigDecimal applied = remaining.min(outstanding).max(BigDecimal.ZERO);
balance.setPrincipalPaid(MathUtil.nullToZero(balance.getPrincipalPaid()).add(applied));
return applied;
}

private BigDecimal allocateToCharges(final List<WorkingCapitalLoanCharge> charges, final boolean penalty, final DueType dueType,
final LocalDate transactionDate, final BigDecimal remaining, final WorkingCapitalLoanBalance balance) {
BigDecimal available = remaining;
BigDecimal totalApplied = BigDecimal.ZERO;
for (final WorkingCapitalLoanCharge charge : charges) {
if (!MathUtil.isGreaterThanZero(available)) {
break;
}
if (charge.isPenaltyCharge() != penalty || !matchesDueType(charge, dueType, transactionDate)) {
continue;
}
final BigDecimal outstanding = charge.getAmountOutstanding();
if (!MathUtil.isGreaterThanZero(outstanding)) {
continue;
}
final BigDecimal applied = available.min(outstanding);
charge.setAmountPaid(MathUtil.nullToZero(charge.getAmountPaid()).add(applied));
if (!MathUtil.isGreaterThanZero(charge.getAmountOutstanding())) {
charge.setPaid(true);
}
available = available.subtract(applied);
totalApplied = totalApplied.add(applied);
}
if (MathUtil.isGreaterThanZero(totalApplied)) {
if (penalty) {
balance.setPenaltyPaid(MathUtil.nullToZero(balance.getPenaltyPaid()).add(totalApplied));
} else {
balance.setFeePaid(MathUtil.nullToZero(balance.getFeePaid()).add(totalApplied));
}
}
return totalApplied;
}

private boolean matchesDueType(final WorkingCapitalLoanCharge charge, final DueType dueType, final LocalDate transactionDate) {
final LocalDate dueDate = charge.getDueDate();
// A charge with no due date is treated as already due; otherwise "due" means on/before the transaction date.
final boolean isDue = dueDate == null || !dueDate.isAfter(transactionDate);
return dueType == DueType.DUE ? isDue : !isDue;
}

private List<WorkingCapitalPaymentAllocationType> resolveAllocationOrder(final WorkingCapitalLoan loan) {
final List<WorkingCapitalLoanPaymentAllocationRule> rules = loan.getPaymentAllocationRules();
if (rules != null && !rules.isEmpty()) {
final WorkingCapitalLoanPaymentAllocationRule repaymentRule = findRule(rules, PaymentAllocationTransactionType.REPAYMENT);
final WorkingCapitalLoanPaymentAllocationRule rule = repaymentRule != null ? repaymentRule
: findRule(rules, PaymentAllocationTransactionType.DEFAULT);
if (rule != null && rule.getAllocationTypes() != null && !rule.getAllocationTypes().isEmpty()) {
return rule.getAllocationTypes();
}
}
// No configured order: keep the legacy principal-only behaviour.
return List.of(WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL);
}

private WorkingCapitalLoanPaymentAllocationRule findRule(final List<WorkingCapitalLoanPaymentAllocationRule> rules,
final PaymentAllocationTransactionType transactionType) {
return rules.stream().filter(rule -> transactionType.equals(rule.getTransactionType())).findFirst().orElse(null);
}

public record AllocationResult(BigDecimal principalPortion, BigDecimal feeChargesPortion, BigDecimal penaltyChargesPortion,
BigDecimal overpaymentPortion) {
}
}
Loading