From 2765b6282ed015efd2d193b3cee592c8d392caaf Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Fri, 5 Jun 2026 15:37:55 +0200 Subject: [PATCH 1/3] FINERACT-2455: working capital transaction reprocessing --- .../domain/WorkingCapitalLoanCharge.java | 2 +- ...rkingCapitalLoanTransactionAllocation.java | 10 + .../WorkingCapitalLoanChargeRepository.java | 2 + ...alLoanTransactionAllocationRepository.java | 7 +- ...lLoanAmortizationScheduleWriteService.java | 12 + ...nAmortizationScheduleWriteServiceImpl.java | 41 ++- ...anDelinquencyRangeScheduleServiceImpl.java | 2 +- ...CapitalLoanPaymentAllocationProcessor.java | 161 +++++++++++ ...talLoanTransactionReprocessingService.java | 54 ++++ ...oanTransactionReprocessingServiceImpl.java | 146 ++++++++++ ...ngCapitalLoanWritePlatformServiceImpl.java | 72 +++-- ...apitalLoanTransactionReprocessingTest.java | 261 ++++++++++++++++++ 12 files changed, 734 insertions(+), 36 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java index 5e5da2b5fda..74416002f16 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java @@ -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, diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java index 686c4e69641..aeab0a79e4c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java @@ -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(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java index 0a7bbc5e56d..ad0a09a21fc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java @@ -35,6 +35,8 @@ public interface WorkingCapitalLoanChargeRepository Long findIdByExternalId(ExternalId externalId); + List 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") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java index ee2d1370a16..93ea87a91bc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java @@ -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 {} +public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository { + + List findByWcLoanTransaction_IdIn(Collection transactionIds); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java index 37d608891dd..d55b58766b2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java @@ -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); @@ -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 principalPayments); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index a05f7c82911..28cc34226a0 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -216,13 +216,8 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { final List 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); @@ -232,6 +227,23 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { scheduleRepositoryWrapper.writeModel(loan, restatedModel); } + @Override + public void rebuildScheduleFromPrincipalPayments(final WorkingCapitalLoan loan, final List 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(); @@ -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; + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java index 9119991a19c..f5dc1da3d02 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl.java @@ -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)); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.java new file mode 100644 index 00000000000..32ab49f1023 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPaymentAllocationProcessor.java @@ -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. + * + *

+ * 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 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 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 resolveAllocationOrder(final WorkingCapitalLoan loan) { + final List 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 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) { + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java new file mode 100644 index 00000000000..da35cfb85e8 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingService.java @@ -0,0 +1,54 @@ +/** + * 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.util.List; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +/** + * Reprocesses transaction allocations for a Working Capital loan after a backdated transaction changes the + * chronological order. (Transaction reversal is the other intended trigger but is not wired yet — it arrives with the + * generic undo work in PS-3209.) + * + *

+ * Transactions themselves are never reversed or replayed — only the allocation split (principal/fee/penalty portions) + * of affected transactions is recalculated. From those recomputed portions this service also rebuilds the loan balance + * (paid amounts) and the amortization schedule. + * + *

+ * The delinquency and breach schedules are intentionally not rebuilt here: they are maintained incrementally + * by the regular transaction flow. This is a known, accepted limitation for the current simple-scenario scope — a + * backdated re-allocation that shifts principal between transactions can leave the per-period paid amounts slightly + * stale, but only when a period boundary falls between the affected dates (the totals are unchanged). + * + *

+ * Allocation order only matters when payments compete for charge buckets. A loan without charges allocates every + * repayment-like transaction to principal only, which is order-independent — reprocessing is a no-op in that case. + */ +public interface WorkingCapitalLoanTransactionReprocessingService { + + void reprocessTransactions(WorkingCapitalLoan loan); + + /** + * Reprocesses using the provided pre-loaded transaction list (avoids a redundant DB query when the caller has + * already fetched them). + */ + void reprocessTransactions(WorkingCapitalLoan loan, List allTransactions); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java new file mode 100644 index 00000000000..5d9eeb6c267 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReprocessingServiceImpl.java @@ -0,0 +1,146 @@ +/** + * 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.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +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.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanAmortizationScheduleWriteService.PrincipalPayment; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class WorkingCapitalLoanTransactionReprocessingServiceImpl implements WorkingCapitalLoanTransactionReprocessingService { + + // Replay order matches the standard loan transaction ordering, simplified for WC (no accrual/income posting): + // transaction date, then submitted date, then id. + private static final Comparator TRANSACTION_ORDER = Comparator + .comparing(WorkingCapitalLoanTransaction::getTransactionDate) + .thenComparing(WorkingCapitalLoanTransaction::getSubmittedOnDate, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(WorkingCapitalLoanTransaction::getId); + + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanChargeRepository chargeRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; + private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; + private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; + + @Override + public void reprocessTransactions(final WorkingCapitalLoan loan) { + final List allTransactions = transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loan.getId()); + reprocessTransactions(loan, allTransactions); + } + + @Override + public void reprocessTransactions(final WorkingCapitalLoan loan, final List allTransactions) { + // Allocation order only matters when payments compete for charge buckets. Without charges, every + // repayment-like transaction allocates to principal only — min(amount, outstanding) — which is + // order-independent, so a changed chronological order cannot change any allocation. + final List charges = chargeRepository.findByLoan_IdAndActiveTrueOrderByDueDateAscIdAsc(loan.getId()); + if (charges.isEmpty()) { + log.debug("Skipping transaction reprocessing for WC loan {}: no active charges, allocations are order-independent", + loan.getId()); + return; + } + + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()).orElse(null); + if (balance == null) { + log.debug("Skipping transaction reprocessing for WC loan {}: no balance to recompute", loan.getId()); + return; + } + + // Reset the paid distribution; the principal/fee/penalty totals stay, only how much of each is paid is + // recomputed. + balance.setPrincipalPaid(BigDecimal.ZERO); + balance.setFeePaid(BigDecimal.ZERO); + balance.setPenaltyPaid(BigDecimal.ZERO); + balance.setOverpaymentAmount(BigDecimal.ZERO); + for (final WorkingCapitalLoanCharge charge : charges) { + charge.setAmountPaid(BigDecimal.ZERO); + charge.setPaid(false); + } + + // Re-allocate every non-reversed repayment-like transaction in chronological order. + final List replayable = allTransactions.stream() + .filter(txn -> !txn.isReversed() && isRepaymentLike(txn.getTypeOf())).sorted(TRANSACTION_ORDER).toList(); + + // Pre-load the existing allocations in one query rather than per transaction. Looking them up via the + // repository (instead of txn.getAllocation()) also avoids the lazy inverse side being stale for the + // transaction that just triggered the reprocessing, which would otherwise create a second allocation row and + // violate the one-allocation-per-transaction unique constraint. + final Map allocationsByTxnId = allocationRepository + .findByWcLoanTransaction_IdIn(replayable.stream().map(WorkingCapitalLoanTransaction::getId).toList()).stream() + .collect(Collectors.toMap(allocation -> allocation.getWcLoanTransaction().getId(), Function.identity())); + + final List principalPayments = new ArrayList<>(); + final List updatedAllocations = new ArrayList<>(); + for (final WorkingCapitalLoanTransaction txn : replayable) { + final WorkingCapitalLoanPaymentAllocationProcessor.AllocationResult result = allocationProcessor.allocate(loan, balance, + charges, txn.getTransactionDate(), txn.getTransactionAmount()); + updatedAllocations.add(applyAllocation(txn, allocationsByTxnId.get(txn.getId()), result)); + principalPayments.add(new PrincipalPayment(txn.getTransactionDate(), result.principalPortion())); + } + + allocationRepository.saveAll(updatedAllocations); + chargeRepository.saveAll(charges); + balanceRepository.saveAndFlush(balance); + + // The amortization schedule depends only on the principal paid per day, which can shift when the principal + // portions are re-allocated; rebuild it from the recomputed portions. + amortizationScheduleWriteService.rebuildScheduleFromPrincipalPayments(loan, principalPayments); + } + + private WorkingCapitalLoanTransactionAllocation applyAllocation(final WorkingCapitalLoanTransaction txn, + final WorkingCapitalLoanTransactionAllocation existing, + final WorkingCapitalLoanPaymentAllocationProcessor.AllocationResult result) { + if (existing == null) { + return WorkingCapitalLoanTransactionAllocation.forPortions(txn, result.principalPortion(), result.feeChargesPortion(), + result.penaltyChargesPortion()); + } + existing.setPrincipalPortion(result.principalPortion()); + existing.setFeeChargesPortion(result.feeChargesPortion()); + existing.setPenaltyChargesPortion(result.penaltyChargesPortion()); + return existing; + } + + private boolean isRepaymentLike(final LoanTransactionType type) { + return type == LoanTransactionType.REPAYMENT || type == LoanTransactionType.GOODWILL_CREDIT; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 3e639a2c0d8..8d1d6d5f2c4 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -60,6 +60,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; 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.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; @@ -71,6 +72,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; @@ -105,6 +107,9 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanTransactionRelationRepository relationRepository; private final WorkingCapitalLoanPeriodPaymentRateChangeRepository rateChangeRepository; private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; + private final WorkingCapitalLoanTransactionReprocessingService transactionReprocessingService; + private final WorkingCapitalLoanChargeRepository chargeRepository; + private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -662,18 +667,42 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, transactionDate, classification, txnExternalId); this.transactionRepository.saveAndFlush(transaction); - final WorkingCapitalLoanBalance currentBalance = this.balanceRepository.findByWcLoan_Id(loan.getId()) + final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - final BigDecimal outstandingBeforeRepayment = MathUtil.nullToZero(currentBalance.getPrincipalOutstanding()); - final BigDecimal amountAppliedToOutstanding = transactionAmount.min(outstandingBeforeRepayment); + final List charges = this.chargeRepository.findByLoan_IdAndActiveTrueOrderByDueDateAscIdAsc(loanId); - final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation - .forPrincipalAllocation(transaction, amountAppliedToOutstanding); + // Allocate the amount across penalty/fee/principal following the loan's configured payment allocation order + // (principal-only when no order is configured). This updates the charge paid amounts and the loan balance. + final WorkingCapitalLoanPaymentAllocationProcessor.AllocationResult allocationResult = allocationProcessor.allocate(loan, balance, + charges, transactionDate, transactionAmount); + this.chargeRepository.saveAll(charges); + this.balanceRepository.saveAndFlush(balance); + + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation.forPortions(transaction, + allocationResult.principalPortion(), allocationResult.feeChargesPortion(), allocationResult.penaltyChargesPortion()); this.allocationRepository.saveAndFlush(allocation); - amortizationScheduleWriteService.applyRepayment(loan, transactionDate, amountAppliedToOutstanding); - updateBalanceOnRepayment(loan, transactionAmount); - internalWorkingCapitalLoanPaymentService.makePayment(loanId, amountAppliedToOutstanding, transactionDate); + // Only the principal portion affects the amortization and delinquency/breach schedules; fee and penalty + // portions settle charges. + final BigDecimal principalPortion = allocationResult.principalPortion(); + + // A backdated transaction can change how the other transactions allocate across charges, so it triggers + // reprocessing. When the loan has charges, reprocessing rebuilds the amortization schedule from scratch, so + // the incremental apply below would be immediately overwritten and is skipped. For a charge-free loan + // reprocessing is a no-op (principal-only allocation is order-independent), so the incremental apply stands. + final List allTransactions = this.transactionRepository + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loanId); + final boolean backdated = isBackdatedTransaction(allTransactions, transaction); + final boolean reprocessingWillRebuildSchedule = backdated && !charges.isEmpty(); + if (!reprocessingWillRebuildSchedule) { + // The amortization model records the principal on its actual day and recalculates forward. + amortizationScheduleWriteService.applyRepayment(loan, transactionDate, principalPortion); + } + if (backdated) { + transactionReprocessingService.reprocessTransactions(loan, allTransactions); + } + // Delinquency and breach schedules are maintained incrementally here; reprocessing does not rebuild them. + internalWorkingCapitalLoanPaymentService.makePayment(loanId, principalPortion, transactionDate); handleStateChanges(loan, transactionDate); triggerInlineAmortizationIfLoanClosed(loan, transactionDate); @@ -933,24 +962,6 @@ private void updateBalanceForDiscountChange(final WorkingCapitalLoan loan, final this.balanceRepository.saveAndFlush(balance); } - private void updateBalanceOnRepayment(final WorkingCapitalLoan loan, final BigDecimal repaymentAmount) { - final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) - .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - final BigDecimal principalOutstanding = balance.getPrincipalOutstanding() != null ? balance.getPrincipalOutstanding() - : BigDecimal.ZERO; - final BigDecimal currentTotalPaidPrincipal = balance.getPrincipalPaid() != null ? balance.getPrincipalPaid() : BigDecimal.ZERO; - final BigDecimal currentOverpayment = balance.getOverpaymentAmount() != null ? balance.getOverpaymentAmount() : BigDecimal.ZERO; - final BigDecimal amountAppliedToOutstanding = repaymentAmount.min(principalOutstanding); - final BigDecimal overpaymentIncrement = repaymentAmount.subtract(amountAppliedToOutstanding).max(BigDecimal.ZERO); - - balance.setOverpaymentAmount(currentOverpayment.add(overpaymentIncrement)); - - final BigDecimal principalPaidInThisRepayment = amountAppliedToOutstanding.max(BigDecimal.ZERO); - balance.setPrincipalPaid(currentTotalPaidPrincipal.add(principalPaidInThisRepayment)); - - this.balanceRepository.saveAndFlush(balance); - } - private void updateBalanceOnCreditBalanceRefund(final WorkingCapitalLoan loan, final BigDecimal refundAmount) { final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); @@ -959,6 +970,15 @@ private void updateBalanceOnCreditBalanceRefund(final WorkingCapitalLoan loan, f this.balanceRepository.saveAndFlush(balance); } + private boolean isBackdatedTransaction(final List allTransactions, + final WorkingCapitalLoanTransaction newTxn) { + // The same-date ID comparison is defensive only: the just-persisted transaction holds the highest + // ID, so in practice only a strictly later transaction date marks the new one as backdated. + return allTransactions.stream().filter(txn -> !txn.isReversed() && !txn.getId().equals(newTxn.getId())) + .anyMatch(txn -> txn.getTransactionDate().isAfter(newTxn.getTransactionDate()) + || (txn.getTransactionDate().equals(newTxn.getTransactionDate()) && txn.getId().compareTo(newTxn.getId()) > 0)); + } + private void reverseTransaction(final WorkingCapitalLoanTransaction txn) { txn.setReversed(true); txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java new file mode 100644 index 00000000000..49b022a7398 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanTransactionReprocessingTest.java @@ -0,0 +1,261 @@ +/** + * 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.integrationtests.client.feign.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for WC Transaction Reprocessing (generic). + * + * Backdated repayments are applied through the regular incremental flow (balance math is order-independent and the + * amortization model records payments on their actual day). The reprocessing engine only recalculates allocations of + * subsequent transactions when payments compete for charge buckets — without charges it is a no-op, which these tests + * verify by asserting that existing allocations stay untouched after a backdated repayment. + * + *

+ * The charge-based re-allocation path (fees/penalties competing across transactions) is covered at the E2E layer in + * {@code WorkingCapitalTransactionReprocessing.feature} (C85212/C85216/C85218); this integration suite focuses on the + * charge-free, order-independent path. + */ +public class FeignWorkingCapitalLoanTransactionReprocessingTest extends FeignIntegrationTest { + + private FeignWorkingCapitalLoanHelper wcLoanHelper; + private FeignClientHelper clientHelper; + private FeignBusinessDateHelper businessDateHelper; + private WorkingCapitalLoanProductHelper productHelper; + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + + @BeforeAll + void setupHelpers() { + wcLoanHelper = new FeignWorkingCapitalLoanHelper(fineractClient()); + clientHelper = new FeignClientHelper(fineractClient()); + businessDateHelper = new FeignBusinessDateHelper(fineractClient()); + productHelper = new WorkingCapitalLoanProductHelper(); + } + + @AfterAll + void cleanupEntities() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + createdProductIds.clear(); + } + + @Test + void testBackdatedRepayment_balanceReflectsBothPayments() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // First repayment on day 10 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterFirstRepayment = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterFirstRepayment.getBalance(), "Balance should exist after repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterFirstRepayment.getBalance().getPrincipalOutstanding(), + "Outstanding should be 6000 after 3000 repayment on 9000 loan"); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterFirstRepayment.getBalance().getPrincipalPaid(), + "Principal paid should be 3000 after first repayment"); + + // Backdated repayment on day 5 (before existing repayment on day 10) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "05 January 2026")); + + // Both repayments should be reflected — balance math is order-independent + GetWorkingCapitalLoansLoanIdResponse afterBackdated = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterBackdated.getBalance(), "Balance should exist after backdated repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(4000), afterBackdated.getBalance().getPrincipalOutstanding(), + "Outstanding should be 4000 after total 5000 repaid on 9000 loan"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), afterBackdated.getBalance().getPrincipalPaid(), + "Principal paid should be 5000 (2000 + 3000)"); + + // Both repayments fit into principal: the backdated one allocates fully, the earlier one stays untouched + List transactions = wcLoanHelper.getTransactions(loanId); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 5), BigDecimal.valueOf(2000)), BigDecimal.valueOf(2000)); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 10), BigDecimal.valueOf(3000)), BigDecimal.valueOf(3000)); + }); + } + + @Test + void testBackdatedRepayment_excessBecomesOverpayment() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // Partial repayment on day 10 (loan stays ACTIVE with 2000 outstanding) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(7000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterRepayment = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterRepayment.getBalance(), "Balance should exist after repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(2000), afterRepayment.getBalance().getPrincipalOutstanding(), + "Outstanding should be 2000 after 7000 repayment on 9000 loan"); + + // Backdated repayment on day 5 — total repaid (5000 + 7000 = 12000) exceeds principal (9000) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(5000), "05 January 2026")); + + // Totals are order-independent: 9000 principal repaid, 3000 overpayment + GetWorkingCapitalLoansLoanIdResponse afterBackdated = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterBackdated.getBalance(), "Balance should exist after backdated repayment"); + assertEqualBigDecimal(BigDecimal.ZERO, afterBackdated.getBalance().getPrincipalOutstanding(), + "Outstanding should be 0 — principal is fully repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(9000), afterBackdated.getBalance().getPrincipalPaid(), + "Principal paid should be 9000 — capped at total principal"); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterBackdated.getBalance().getOverpaymentAmount(), + "Overpayment should be 3000 (5000 + 7000 - 9000 principal)"); + + // Without charges, allocations are not redistributed: the day-10 repayment keeps its original + // 7000 principal allocation, and the backdated day-5 repayment allocates against the 2000 that + // was outstanding when it was booked (its excess 3000 is overpayment, not part of the allocation). + List transactions = wcLoanHelper.getTransactions(loanId); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 5), BigDecimal.valueOf(5000)), BigDecimal.valueOf(2000)); + assertAllocation(findTransaction(transactions, LocalDate.of(2026, 1, 10), BigDecimal.valueOf(7000)), BigDecimal.valueOf(7000)); + }); + } + + @Test + void testMultipleBackdatedRepaymentsAccumulateCorrectly() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // First repayment on day 15 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-15"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "15 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterFirst = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterFirst.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterFirst.getBalance().getPrincipalOutstanding(), + "Outstanding should be 6000 after first repayment"); + + // Backdated repayment on day 5 + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-20"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(1000), "05 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterSecond = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterSecond.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), afterSecond.getBalance().getPrincipalOutstanding(), + "Outstanding should be 5000 after 4000 total repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(4000), afterSecond.getBalance().getPrincipalPaid(), + "Principal paid should be 4000 (1000 + 3000)"); + + // Another backdated repayment on day 10 (between existing ones) + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "10 January 2026")); + + GetWorkingCapitalLoansLoanIdResponse afterThird = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(afterThird.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(3000), afterThird.getBalance().getPrincipalOutstanding(), + "Outstanding should be 3000 after 6000 total repaid"); + assertEqualBigDecimal(BigDecimal.valueOf(6000), afterThird.getBalance().getPrincipalPaid(), + "Principal paid should be 6000 (1000 + 2000 + 3000)"); + }); + } + + @Test + void testNonBackdatedRepaymentDoesNotTriggerReprocessing() { + businessDateHelper.runAt("2026-01-01", () -> { + Long clientForTest = clientHelper.createClient("01 January 2026"); + Long loanId = createAndDisburseLoanOnDate(clientForTest, BigDecimal.valueOf(9000), "01 January 2026"); + + // Sequential repayments (not backdated — each on or after the business date) + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-05"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(2000), "05 January 2026")); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-10"); + wcLoanHelper.makeRepayment(loanId, WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(3000), "10 January 2026")); + + // Verify balance is the simple sum — no reprocessing side effects + GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(loan.getBalance()); + assertEqualBigDecimal(BigDecimal.valueOf(4000), loan.getBalance().getPrincipalOutstanding(), + "Outstanding should be 4000 after sequential 2000 + 3000 repayments"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), loan.getBalance().getPrincipalPaid(), + "Principal paid should be 5000 after sequential repayments"); + assertEqualBigDecimal(BigDecimal.ZERO, loan.getBalance().getOverpaymentAmount(), + "No overpayment expected for sequential repayments under principal"); + }); + } + + private Long createAndDisburseLoanOnDate(Long clientIdParam, BigDecimal principal, String date) { + Long productId = createProduct(); + Long loanId = submitAndTrack(clientIdParam, productId, principal, date); + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approve(date, principal, date)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburse(date, principal)); + return loanId; + } + + private Long submitAndTrack(Long clientIdParam, Long productId, BigDecimal principal, String date) { + Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplication(clientIdParam, productId, + principal, BigDecimal.valueOf(18), date, date)); + createdLoanIds.add(loanId); + return loanId; + } + + private Long createProduct() { + String uniqueName = "WCL Reprocess " + Utils.uniqueRandomStringGenerator("", 8); + String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private static GetWorkingCapitalLoanTransactionIdResponse findTransaction(List transactions, + LocalDate transactionDate, BigDecimal amount) { + return transactions.stream().filter(txn -> transactionDate.equals(txn.getTransactionDate())) + .filter(txn -> txn.getTransactionAmount() != null && amount.compareTo(txn.getTransactionAmount()) == 0).findFirst() + .orElseThrow(() -> new AssertionError("Transaction not found on " + transactionDate + " with amount " + amount)); + } + + private static void assertAllocation(GetWorkingCapitalLoanTransactionIdResponse transaction, BigDecimal expectedPrincipalPortion) { + String context = "Transaction on " + transaction.getTransactionDate() + " amount " + transaction.getTransactionAmount(); + assertEqualBigDecimal(expectedPrincipalPortion, transaction.getPrincipalPortion(), context + " — principal portion"); + assertEqualBigDecimal(BigDecimal.ZERO, transaction.getFeeChargesPortion(), context + " — fee charges portion"); + assertEqualBigDecimal(BigDecimal.ZERO, transaction.getPenaltyChargesPortion(), context + " — penalty charges portion"); + } + + private static void assertEqualBigDecimal(BigDecimal expected, BigDecimal actual, String message) { + assertNotNull(actual, message + " — value was null"); + assertEquals(0, expected.compareTo(actual), message + " — expected: " + expected + " but was: " + actual); + } +} From d50f9c809d817dd16ea2666c5560a2e758d47465 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Mon, 15 Jun 2026 15:24:39 +0200 Subject: [PATCH 2/3] FINERACT-2455: added e2e tests for verifying working capital transaction reprocessing --- ...kingCapitalTransactionReprocessing.feature | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature new file mode 100644 index 00000000000..a344d638c97 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature @@ -0,0 +1,318 @@ +@WorkingCapital +@WorkingCapitalTransactionReprocessingFeature +Feature: Working Capital Transaction Reprocessing + + @TestRailId:C85208 + Scenario: Verify backdated repayment is reflected in balances and existing allocations stay untouched (no charges) + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + # First repayment on day 10 + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 6000.0 | + | totalPaidPrincipal | 3000.0 | + # Backdated repayment on day 5 (earlier than the existing day-10 repayment) + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + # Both repayments are reflected - balance math is order-independent + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + | totalPaidPrincipal | 5000.0 | + | overpaymentAmount | 0.0 | + # The backdated repayment allocates fully to principal; the earlier repayment stays untouched + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85209 + Scenario: Verify backdated repayment that overpays - excess becomes overpayment, allocations not redistributed + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + # Partial repayment on day 10 (loan stays ACTIVE with 2000 outstanding) + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 7000 transaction amount on Working Capital loan + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 2000.0 | + # Backdated repayment on day 5 - total repaid (7000 + 5000 = 12000) exceeds principal (9000) + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 5000 transaction amount on Working Capital loan + # Totals are order-independent: 9000 principal repaid, 3000 overpayment + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 0.0 | + | totalPaidPrincipal | 9000.0 | + | overpaymentAmount | 3000.0 | + # Without reprocessing, the day-10 repayment keeps its 7000 principal allocation, and the backdated day-5 repayment + # allocates against the 2000 that was outstanding when it was booked (its excess 3000 is overpayment, not allocation). + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 5000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 7000.0 | 7000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85210 + Scenario: Verify multiple backdated repayments accumulate correctly + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + # First repayment on day 15 + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "15 January 2026" with 3000 transaction amount on Working Capital loan + # Backdated repayment on day 5 + When Admin sets the business date to "20 January 2026" + And Customer makes repayment on "05 January 2026" with 1000 transaction amount on Working Capital loan + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 5000.0 | + | totalPaidPrincipal | 4000.0 | + # Another backdated repayment on day 10 (between the existing ones) + And Customer makes repayment on "10 January 2026" with 2000 transaction amount on Working Capital loan + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 3000.0 | + | totalPaidPrincipal | 6000.0 | + | overpaymentAmount | 0.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85211 + Scenario: Verify sequential (non-backdated) repayments do not trigger reprocessing side effects + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + | totalPaidPrincipal | 5000.0 | + | overpaymentAmount | 0.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + + @TestRailId:C85212 + Scenario: Verify backdated repayment on a loan WITH an active charge leaves allocations principal-only + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 35.0 transaction amount + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + # Backdated repayment on day 5 -> reprocessing is triggered, finds an active charge, and no-ops + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + | totalPaidPrincipal | 5000.0 | + # Repayments are still allocated entirely to principal - the charge was NOT covered by the re-allocation + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Paid | Fee Outstanding | + | 35.0 | 0.0 | 35.0 | + + @TestRailId:C85213 + Scenario: Verify a repayment clearing more than one lapsed delinquency period distributes by remaining balance + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + # Let period 1 (01 Jan - 30 Jan) lapse unpaid and be evaluated as not met + When Admin sets the business date to "31 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # Let period 2 (31 Jan - 01 Mar) lapse unpaid and be evaluated as not met + When Admin sets the business date to "02 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + # Single repayment of 400 covering period 1 fully (270) and period 2 partially (130) + And Customer makes repayment on "02 March 2026" with 400 transaction amount on Working Capital loan + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 270.0 | 0.0 | true | 0.0 | 0 | + | 2 | 2026-01-31 | 2026-03-01 | 270.0 | 130.0 | 140.0 | false | 140.0 | 1 | + | 3 | 2026-03-02 | 2026-03-31 | 270.0 | 0.0 | 270.0 | null | null | null | + + @TestRailId:C85214 + Scenario: Verify backdated repayment is recorded on its actual day and the amortization balance is recalculated + 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 with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + # Outstanding recomputed by the amortization model after the backdated payment + Then Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 4000.0 | + # The projected amortization schedule is intact (structure is a static projection); the recomputed outstanding + # balance above is the model's actual output that reflects the backdated payment. + When Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | totalPaymentVolume | periodPaymentRate | npvDayCount | expectedPaymentAmount | + | 0.00 | 9000.00 | 100000.00 | 18 | 360 | 50.00 | + + @TestRailId:C85215 + Scenario: Verify backdated repayment reduces the outstanding tracked by the breach schedule + 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 breachId and overrides enabled + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan + # Backdated repayment on day 5 + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has 1 period + + @TestRailId:C85216 + Scenario: Verify backdated repayment re-allocates the later transaction's fee portion to principal + # Txn#1 (day 10): pays 5 NSF-Fee + 25 principal (amount 30) + # Txn#2 backdated (day 5): pays 5 NSF-Fee + 20 principal (amount 25) + # => Txn#1 must be reprocessed and re-allocate to 30 principal (the fee is now taken by the earlier Txn#2) + 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: + | DUE_PENALTY | + | DUE_FEE | + | DUE_PRINCIPAL | + | IN_ADVANCE_PENALTY | + | IN_ADVANCE_FEE | + | IN_ADVANCE_PRINCIPAL | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 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 5.0 transaction amount + And Customer makes repayment on "10 January 2026" with 30 transaction amount on Working Capital loan + # First, Txn#1 allocates 5 fee + 25 principal + Then Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 30.0 | 25.0 | 5.0 | 0.0 | false | + # Backdated Txn#2 on day 5 takes the fee; Txn#1 is reprocessed to 30 principal + When Admin sets the business date to "15 January 2026" + And Customer makes repayment on "05 January 2026" with 25 transaction amount on Working Capital loan + Then Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 25.0 | 20.0 | 5.0 | 0.0 | false | + | 10 January 2026 | Repayment | 30.0 | 30.0 | 0.0 | 0.0 | false | + + @Skip + @TestRailId:C85217 + Scenario: Verify reversal of a transaction re-allocates the remaining transaction's fee portion + # Txn#1 (day 5): pays 5 NSF-Fee + 25 principal (amount 30) + # Txn#2 (day 10): pays 25 principal + # Txn#1 reverted => Txn#2 must be reprocessed and re-allocate to 5 NSF-Fee + 20 principal + # Requires: (a) fee/penalty payment allocation, (b) reversal-triggered reprocessing, (c) repayment-undo support + # (today undoTransaction rejects everything but DISCOUNT_FEE_ADJUSTMENT) - and a new "reverse repayment" step def. + 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: + | DUE_PENALTY | + | DUE_FEE | + | DUE_PRINCIPAL | + | IN_ADVANCE_PENALTY | + | IN_ADVANCE_FEE | + | IN_ADVANCE_PRINCIPAL | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "05 January 2026" due date and 5.0 transaction amount + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 30 transaction amount on Working Capital loan + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 25 transaction amount on Working Capital loan + # Reverse Txn#1 (the day-5 repayment) -> Txn#2 must be reprocessed to take the fee + #And Admin reverses the "05 January 2026" repayment with 30 transaction amount on Working Capital loan + # Then Working Capital Loan has transactions: + # | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + # | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + # | 05 January 2026 | Repayment | 30.0 | 25.0 | 5.0 | 0.0 | true | + # | 10 January 2026 | Repayment | 25.0 | 20.0 | 5.0 | 0.0 | false | + + @TestRailId:C85218 + Scenario: Verify a repayment splits across fee and principal per the product's allocation order + 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: + | DUE_PENALTY | + | DUE_FEE | + | DUE_PRINCIPAL | + | IN_ADVANCE_PENALTY | + | IN_ADVANCE_FEE | + | IN_ADVANCE_PRINCIPAL | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "10 January 2026" + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 5.0 transaction amount + And Customer makes repayment on "10 January 2026" with 30 transaction amount on Working Capital loan + # Fee-first allocation order: 5 to fee, 25 to principal + Then Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 30.0 | 25.0 | 5.0 | 0.0 | false | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Paid | Fee Outstanding | + | 5.0 | 5.0 | 0.0 | From 8e98dc1aca464b5fc83eb330dee1361ebca6a559 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Tue, 16 Jun 2026 15:17:04 +0200 Subject: [PATCH 3/3] FINERACT-2455: e2e tests for WC fee allocation and charge-aware reprocessing --- .../stepdef/loan/WorkingCapitalStepDef.java | 5 +++- ...kingCapitalTransactionReprocessing.feature | 24 +++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java index 56eb5ee4a7d..962d8e42f78 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java @@ -1878,7 +1878,10 @@ public void verifyTemplateAdvancedPaymentAllocationTypes(final DataTable table) public void createWorkingCapitalLoanProductWithPaymentAllocationOrder(final DataTable table) { final List 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))); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature index a344d638c97..1a68da3ec76 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalTransactionReprocessing.feature @@ -58,8 +58,9 @@ Feature: Working Capital Transaction Reprocessing | principalOutstanding | 0.0 | | totalPaidPrincipal | 9000.0 | | overpaymentAmount | 3000.0 | - # Without reprocessing, the day-10 repayment keeps its 7000 principal allocation, and the backdated day-5 repayment - # allocates against the 2000 that was outstanding when it was booked (its excess 3000 is overpayment, not allocation). + # Reprocessing is triggered (the day-5 repayment is backdated) but no-ops because the loan has no charges - + # principal-only allocation is order-independent. The day-10 repayment keeps its 7000 principal allocation, and the + # backdated day-5 repayment allocates against the 2000 outstanding when booked (its excess 3000 is overpayment). And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | @@ -124,7 +125,7 @@ Feature: Working Capital Transaction Reprocessing | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | @TestRailId:C85212 - Scenario: Verify backdated repayment on a loan WITH an active charge leaves allocations principal-only + Scenario: Verify backdated repayment only settles a charge that is already due on the backdated date 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 with the following data: @@ -136,22 +137,23 @@ Feature: Working Capital Transaction Reprocessing And Admin runs inline COB job for Working Capital Loan by loanId And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 35.0 transaction amount And Customer makes repayment on "10 January 2026" with 3000 transaction amount on Working Capital loan - # Backdated repayment on day 5 -> reprocessing is triggered, finds an active charge, and no-ops + # Backdated repayment on day 5 -> reprocessing is triggered. The fee is due on day 10, so it is NOT yet due on + # day 5: the backdated repayment stays principal-only, while the day-10 repayment settles the fee (fee-first order). When Admin sets the business date to "15 January 2026" And Customer makes repayment on "05 January 2026" with 2000 transaction amount on Working Capital loan Then Working Capital loan balance payload contains the following fields: | field | value | - | principalOutstanding | 4000.0 | - | totalPaidPrincipal | 5000.0 | - # Repayments are still allocated entirely to principal - the charge was NOT covered by the re-allocation + | principalOutstanding | 4035.0 | + | totalPaidPrincipal | 4965.0 | + # The day-5 repayment predates the fee due date, so it is principal-only; the day-10 repayment covers the fee And Working Capital Loan has transactions: | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | | 05 January 2026 | Repayment | 2000.0 | 2000.0 | 0.0 | 0.0 | false | - | 10 January 2026 | Repayment | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Repayment | 3000.0 | 2965.0 | 35.0 | 0.0 | false | And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Paid | Fee Outstanding | - | 35.0 | 0.0 | 35.0 | + | 35.0 | 35.0 | 0.0 | @TestRailId:C85213 Scenario: Verify a repayment clearing more than one lapsed delinquency period distributes by remaining balance @@ -237,8 +239,10 @@ Feature: Working Capital Transaction Reprocessing | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount - When Admin sets the business date to "10 January 2026" + # Add the fee while the business date is on its due date - charges cannot be dated before the business date + 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 5.0 transaction amount + When Admin sets the business date to "10 January 2026" And Customer makes repayment on "10 January 2026" with 30 transaction amount on Working Capital loan # First, Txn#1 allocates 5 fee + 25 principal Then Working Capital Loan has transactions: