From d602928ae0a92e8411e57d0a5a23afe9b8c6b081 Mon Sep 17 00:00:00 2001 From: "mark.vituska" Date: Tue, 7 Jul 2026 13:02:34 +0200 Subject: [PATCH 1/3] FINERACT-2455: Fix payment allocation rule processing --- .../domain/LoanTransactionType.java | 2 +- ...ountingProcessorForWorkingCapitalLoan.java | 32 ++- ...ingProcessorForWorkingCapitalLoanTest.java | 107 +++++++ ...rkingCapitalLoanLifecycleStateMachine.java | 30 ++ ...rkingCapitalLoanTransactionAllocation.java | 9 - .../WorkingCapitalLoanDataValidator.java | 6 +- .../WorkingCapitalLoanBalanceUpdater.java | 18 -- ...talLoanChargeWritePlatformServiceImpl.java | 17 +- ...orkingCapitalLoanTransactionProcessor.java | 130 +++++++++ ...oanTransactionReprocessingServiceImpl.java | 22 -- ...ngCapitalLoanWritePlatformServiceImpl.java | 101 +------ ...pitalLoanAllocationRequestFactoryTest.java | 103 +++++++ .../FeignWorkingCapitalLoanHelper.java | 7 + .../WorkingCapitalLoanRequestBuilders.java | 5 + ...orkingCapitalLoanChargeAllocationTest.java | 1 + ...gCapitalLoanPaymentAllocationRuleTest.java | 265 ++++++++++++++++++ .../WorkingCapitalLoanProductTestBuilder.java | 59 ++-- 17 files changed, 722 insertions(+), 192 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAllocationRequestFactoryTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java index 4c3f68cc1a2..f080ef700ab 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionType.java @@ -192,7 +192,7 @@ public boolean isChargeRefund() { public boolean isRepaymentType() { return (isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund() || isDownPayment() - || isInterestPaymentWaiver()); + || isInterestPaymentWaiver() || isChargeAdjustment()); } public boolean isRecoveryRepayment() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java index 8d59c17957b..ef1b0de99b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoan.java @@ -32,6 +32,7 @@ import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; @@ -85,8 +86,8 @@ public void postJournalEntries(final WorkingCapitalLoan loan, final WorkingCapit } } case LoanTransactionType.CREDIT_BALANCE_REFUND -> postCreditBalanceRefundJournalEntries(loan, txn); - case LoanTransactionType.CHARGE_ADJUSTMENT -> - postChargeAdjustmentJournalEntries(loan, txn, feesPortion, penaltiesPortion, isChargedOff); + case LoanTransactionType.CHARGE_ADJUSTMENT -> postChargeAdjustmentJournalEntries(loan, txn, principalPortion, feesPortion, + penaltiesPortion, overpaymentPortion, isChargedOff); default -> { throw new NotImplementedException( "Post Journal Entries is not implemented yet for " + txn.getTypeOf().getCode() + " for Working Capital Loan"); @@ -95,20 +96,29 @@ public void postJournalEntries(final WorkingCapitalLoan loan, final WorkingCapit } private void postChargeAdjustmentJournalEntries(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn, - final BigDecimal feesPortion, final BigDecimal penaltiesPortion, final boolean isChargedOff) { + final BigDecimal principalPortion, final BigDecimal feesPortion, final BigDecimal penaltiesPortion, + final BigDecimal overpaymentPortion, final boolean isChargedOff) { final JournalEntryPostingHelper accountPostHelper = new JournalEntryPostingHelper(loan, txn); - final CashAccountsForLoan feeIncomeAccountType = isChargedOff ? CashAccountsForLoan.INCOME_FROM_RECOVERY - : CashAccountsForLoan.INCOME_FROM_FEES; - final CashAccountsForLoan penaltyIncomeAccountType = isChargedOff ? CashAccountsForLoan.INCOME_FROM_RECOVERY - : CashAccountsForLoan.INCOME_FROM_PENALTIES; - // Debit income (reverse income recognized at accrual time) - accountPostHelper.postDebitJournalEntry(feeIncomeAccountType, feesPortion); - accountPostHelper.postDebitJournalEntry(penaltyIncomeAccountType, penaltiesPortion); + final CashAccountsForLoan incomeAccountType = isChargedOff ? CashAccountsForLoan.INCOME_FROM_RECOVERY + : isAdjustedChargeAPenalty(txn) ? CashAccountsForLoan.INCOME_FROM_PENALTIES : CashAccountsForLoan.INCOME_FROM_FEES; + + // debit + accountPostHelper.postDebitJournalEntry(incomeAccountType, txn.getTransactionAmount()); - // Credit receivable (reduce the outstanding charge) + // credit + accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.LOAN_PORTFOLIO, principalPortion); accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.FEES_RECEIVABLE, feesPortion); accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.PENALTIES_RECEIVABLE, penaltiesPortion); + accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.OVERPAYMENT, overpaymentPortion); + } + + private boolean isAdjustedChargeAPenalty(final WorkingCapitalLoanTransaction txn) { + return txn.getLoanTransactionRelations().stream() + .filter(relation -> relation.getToCharge() != null + && relation.getRelationType() == LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT) + .findFirst().map(relation -> relation.getToCharge().isPenaltyCharge()).orElseThrow(() -> new IllegalStateException( + "Charge adjustment transaction " + txn.getId() + " is missing its link to the adjusted charge")); } private void postGoodwillCreditJournalEntries(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, BigDecimal principalPortion, diff --git a/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoanTest.java b/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoanTest.java index 7c53907906c..f098683e889 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoanTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/accounting/journalentry/service/AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapitalLoanTest.java @@ -19,6 +19,7 @@ package org.apache.fineract.accounting.journalentry.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -33,6 +34,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.journalentry.domain.JournalEntry; @@ -44,12 +46,15 @@ import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymenttype.domain.PaymentType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +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.domain.WorkingCapitalLoanTransactionRelation; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; import org.junit.jupiter.api.AfterEach; @@ -107,6 +112,10 @@ class AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapital private GLAccount penaltiesReceivableGLAccount; @Mock private GLAccount incomeFromRecoveryGLAccount; + @Mock + private GLAccount incomeFromFeesGLAccount; + @Mock + private GLAccount incomeFromPenaltiesGLAccount; @BeforeEach void setUp() { @@ -141,6 +150,19 @@ void setUp() { eq(CashAccountsForLoan.PENALTIES_RECEIVABLE.getValue()), any())).thenReturn(penaltiesReceivableGLAccount); lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), eq(CashAccountsForLoan.INCOME_FROM_RECOVERY.getValue()), any())).thenReturn(incomeFromRecoveryGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), + eq(CashAccountsForLoan.INCOME_FROM_FEES.getValue()), any())).thenReturn(incomeFromFeesGLAccount); + lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID), + eq(CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()), any())).thenReturn(incomeFromPenaltiesGLAccount); + } + + private void mockChargeAdjustmentRelation(final boolean penaltyCharge) { + final WorkingCapitalLoanCharge charge = org.mockito.Mockito.mock(WorkingCapitalLoanCharge.class); + lenient().when(charge.isPenaltyCharge()).thenReturn(penaltyCharge); + final WorkingCapitalLoanTransactionRelation relation = org.mockito.Mockito.mock(WorkingCapitalLoanTransactionRelation.class); + lenient().when(relation.getToCharge()).thenReturn(charge); + lenient().when(relation.getRelationType()).thenReturn(LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT); + lenient().when(txn.getLoanTransactionRelations()).thenReturn(Set.of(relation)); } @AfterEach @@ -335,4 +357,89 @@ void testAdvanceAccountingUsesPaymentChannelFundSource() { verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(paymentChannelFundSource), eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("1000")), eq(paymentDetail)); } + + @Test + void testChargeAdjustmentOnFeeChargeDebitsFeeIncomeAndCreditsFeeReceivable() { + when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT); + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("40")); + mockChargeAdjustmentRelation(false); + when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("40")); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + processor.postJournalEntries(loan, txn, allocation, false); + + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromFeesGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull()); + } + + @Test + void testChargeAdjustmentOnPenaltyChargeDebitsPenaltyIncomeAndCreditsPenaltyReceivable() { + when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT); + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("25")); + mockChargeAdjustmentRelation(true); + when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getPenaltyChargesPortion()).thenReturn(new BigDecimal("25")); + + processor.postJournalEntries(loan, txn, allocation, false); + + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromPenaltiesGLAccount), + eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("25")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(penaltiesReceivableGLAccount), + eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("25")), isNull()); + } + + @Test + void testChargeAdjustmentSpillingOntoPrincipalDebitsFullAmountAgainstChargesOwnIncomeAccount() { + when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT); + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("60")); + mockChargeAdjustmentRelation(false); + when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("40")); + when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("20")); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + processor.postJournalEntries(loan, txn, allocation, false); + + // Debit is the FULL transaction amount against the adjusted charge's own income account, regardless of + // where the credit side ends up. + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromFeesGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("60")), isNull()); + // Credit is split by where the allocation actually landed. + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("20")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(loanPortfolioGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull()); + } + + @Test + void testChargedOffChargeAdjustmentDebitsRecoveryInsteadOfFeeIncome() { + when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT); + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("40")); + mockChargeAdjustmentRelation(false); + when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("40")); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + processor.postJournalEntries(loan, txn, allocation, true); + + verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromRecoveryGLAccount), + eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull()); + verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID), + eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull()); + } + + @Test + void testChargeAdjustmentWithoutChargeLinkFailsFast() { + when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT); + when(txn.getTransactionAmount()).thenReturn(new BigDecimal("40")); + when(txn.getLoanTransactionRelations()).thenReturn(Set.of()); + when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO); + when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("40")); + when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + assertThrows(IllegalStateException.class, () -> processor.postJournalEntries(loan, txn, allocation, false)); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java index 636efcbcf0e..fc4d43c672e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java @@ -18,7 +18,10 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.domain; +import java.math.BigDecimal; +import java.time.LocalDate; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.springframework.stereotype.Component; @@ -39,6 +42,33 @@ public boolean canTransition(final WorkingCapitalLoanEvent event, final WorkingC return getNextStatus(event, loan) != null; } + /** + * Determines and applies the status transition implied by the loan's current balance after a monetary transaction: + * overpaid, repaid in full, or reopened from a matured state. Mirrors + * {@code DefaultLoanLifecycleStateMachine.determineAndTransition} in the core loan module. + */ + public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDate transactionDate) { + if (loan.getBalance() == null) { + return; + } + final BigDecimal overpaymentAmount = MathUtil.nullToZero(loan.getBalance().getOverpaymentAmount()); + final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding()); + if (overpaymentAmount.compareTo(BigDecimal.ZERO) > 0) { + transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan); + if (loan.getMaturedOnDate() == null) { + loan.setMaturedOnDate(transactionDate); + } + } else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) { + transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan); + if (loan.getMaturedOnDate() == null) { + loan.setMaturedOnDate(transactionDate); + } + } else if (principalOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null) { + transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan); + loan.setMaturedOnDate(null); + } + } + private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) { LoanStatus from = loan.getLoanStatus(); if (from == null) { 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 00ff67df9ec..7ac71159718 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 @@ -100,13 +100,4 @@ public static WorkingCapitalLoanTransactionAllocation forDiscountFeeAdjustment(f return allocation; } - public static WorkingCapitalLoanTransactionAllocation forChargeAdjustment(final WorkingCapitalLoanTransaction transaction, - final BigDecimal amount, final boolean isPenalty) { - final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation(); - allocation.wcLoanTransaction = transaction; - allocation.principalPortion = BigDecimal.ZERO; - allocation.feeChargesPortion = isPenalty ? BigDecimal.ZERO : MathUtil.nullToZero(amount); - allocation.penaltyChargesPortion = isPenalty ? MathUtil.nullToZero(amount) : BigDecimal.ZERO; - return allocation; - } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index f3cacb8cfcc..6de1acb3e3b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -632,7 +632,7 @@ public void validateUndoDisbursal(final String json) { throwExceptionIfValidationWarningsExist(dataValidationErrors); } - public void validateRepayment(final String json, final WorkingCapitalLoan loan, LoanTransactionType goodwillCredit) { + public void validateRepayment(final String json, final WorkingCapitalLoan loan, LoanTransactionType transactionType) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); } @@ -698,13 +698,13 @@ public void validateRepayment(final String json, final WorkingCapitalLoan loan, validatePaymentDetails(baseDataValidator, element); throwExceptionIfValidationWarningsExist(dataValidationErrors); - if (LoanTransactionType.REPAYMENT.equals(goodwillCredit)) { + if (LoanTransactionType.REPAYMENT.equals(transactionType)) { if (!LoanStatus.ACTIVE.equals(loan.getLoanStatus()) && !LoanStatus.CLOSED_OBLIGATIONS_MET.equals(loan.getLoanStatus()) && !LoanStatus.OVERPAID.equals(loan.getLoanStatus())) { throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", "Repayment is allowed only for active/overpaid loans", WorkingCapitalLoanConstants.loanStatusParamName); } - } else if (LoanTransactionType.GOODWILL_CREDIT.equals(goodwillCredit)) { + } else if (LoanTransactionType.GOODWILL_CREDIT.equals(transactionType)) { if (!LoanStatus.ACTIVE.equals(loan.getLoanStatus()) && !LoanStatus.CLOSED_OBLIGATIONS_MET.equals(loan.getLoanStatus()) && !LoanStatus.OVERPAID.equals(loan.getLoanStatus())) { throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBalanceUpdater.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBalanceUpdater.java index 7b7bb98b0f2..bde75ac19a1 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBalanceUpdater.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanBalanceUpdater.java @@ -18,11 +18,9 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; -import java.math.BigDecimal; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; -import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; import org.springframework.stereotype.Component; /** @@ -50,20 +48,4 @@ public void apply(final WorkingCapitalLoanBalance balance, final WorkingCapitalL balance.setPenaltyPaid(MathUtil.nullToZero(balance.getPenaltyPaid()).add(plan.penaltyChargesPortion())); balance.setOverpaymentAmount(MathUtil.nullToZero(balance.getOverpaymentAmount()).add(plan.overpaymentPortion())); } - - /** - * Refreshes the matching fee/penalty bucket for a single charge payment (the charge adjustment flow, which settles - * one charge directly without going through an allocation plan). - */ - public void applyChargePayment(final WorkingCapitalLoanBalance balance, final WorkingCapitalLoanCharge charge, - final BigDecimal amount) { - if (!MathUtil.isGreaterThanZero(amount)) { - return; - } - if (charge.isPenaltyCharge()) { - balance.setPenaltyPaid(MathUtil.nullToZero(balance.getPenaltyPaid()).add(amount)); - } else { - balance.setFeePaid(MathUtil.nullToZero(balance.getFeePaid()).add(amount)); - } - } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java index 754e6bfc3d3..32e93bdff8c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java @@ -62,7 +62,6 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; -import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanChargeConstants; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanChargeDataValidator; @@ -80,14 +79,12 @@ public class WorkingCapitalLoanChargeWritePlatformServiceImpl implements Working private final ExternalIdFactory externalIdFactory; private final WorkingCapitalLoanBalanceRepository balanceRepository; private final WorkingCapitalLoanTransactionRepository transactionRepository; - private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; private final WorkingCapitalLoanTransactionRelationRepository relationRepository; private final PaymentDetailWritePlatformService paymentDetailService; private final WorkingCapitalLoanNoteRepository noteRepository; private final BusinessEventNotifierService businessEventNotifierService; private final WorkingCapitalLoanAccountingProcessor accountingProcessor; - private final WorkingCapitalLoanChargePaymentHandler chargePaymentHandler; - private final WorkingCapitalLoanBalanceUpdater balanceUpdater; + private final WorkingCapitalLoanTransactionProcessor transactionProcessor; @Override public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command) { @@ -150,16 +147,8 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final adjustmentTx.getLoanTransactionRelations().add(relation); transactionRepository.saveAndFlush(adjustmentTx); - final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation.forChargeAdjustment(adjustmentTx, - amount, wcCharge.isPenaltyCharge()); - allocationRepository.saveAndFlush(allocation); - - final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()) - .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - chargePaymentHandler.applyChargePayment(wcCharge, amount); - balanceUpdater.applyChargePayment(balance, wcCharge, amount); - loanChargeRepository.save(wcCharge); - balanceRepository.save(balance); + final WorkingCapitalLoanTransactionAllocation allocation = transactionProcessor.processRepaymentLikeTransaction(loan, adjustmentTx, + LoanTransactionType.CHARGE_ADJUSTMENT, transactionDate, amount); if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { accountingProcessor.postJournalEntries(loan, adjustmentTx, allocation, false); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java new file mode 100644 index 00000000000..db17bf598ba --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionProcessor.java @@ -0,0 +1,130 @@ +/** + * 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 lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; +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.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WorkingCapitalLoanTransactionProcessor { + + private final WorkingCapitalLoanChargeRepository chargeRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; + private final WorkingCapitalLoanAllocationRequestFactory allocationRequestFactory; + private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; + private final WorkingCapitalLoanAllocationApplier allocationApplier; + private final WorkingCapitalLoanBalanceUpdater balanceUpdater; + private final WorkingCapitalLoanTransactionReprocessingService transactionReprocessingService; + private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; + private final WorkingCapitalLoanBreachScheduleService breachScheduleService; + private final WorkingCapitalLoanDelinquencyRangeScheduleService delinquencyRangeScheduleService; + private final WorkingCapitalLoanLifecycleStateMachine stateMachine; + private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; + + public WorkingCapitalLoanTransactionAllocation processRepaymentLikeTransaction(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction transaction, final LoanTransactionType transactionType, final LocalDate transactionDate, + final BigDecimal transactionAmount) { + final Long loanId = loan.getId(); + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loanId) + .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); + final List charges = chargeRepository.findByLoanIdAndActiveTrueOrderByDueDateAscIdAsc(loanId); + + // Decide the allocation across penalty/fee/principal following the loan's configured payment allocation order + // (principal-only when no order is configured), then materialize it onto the charges and refresh the balance. + final WorkingCapitalLoanAllocationRequest allocationRequest = allocationRequestFactory.build(loan, balance, charges, + transactionDate, transactionAmount, transactionType); + final WorkingCapitalLoanAllocationPlan allocationPlan = allocationProcessor.plan(allocationRequest); + final WorkingCapitalLoanTransactionAllocation allocation = allocationApplier.apply(transaction, null, allocationPlan, charges); + balanceUpdater.apply(balance, allocationPlan); + chargeRepository.saveAll(charges); + balanceRepository.saveAndFlush(balance); + allocationRepository.saveAndFlush(allocation); + + // 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 = 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, allocationPlan.principalPortion()); + } + if (backdated) { + transactionReprocessingService.reprocessTransactions(loan, allTransactions); + // A changed chronological order can redistribute principal across days in ways the incremental + // applyRepayment below can't express, so the delinquency schedule needs a full rebuild too. + delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); + } else { + delinquencyRangeScheduleService.applyRepayment(loan, transactionDate, transactionAmount); + } + // Breach schedule is maintained incrementally here; reprocessing does not rebuild it. + breachScheduleService.applyRepayment(loanId, transactionDate, transactionAmount); + + stateMachine.determineAndTransition(loan, transactionDate); + triggerInlineAmortizationIfLoanClosed(loan, transactionDate); + + return allocation; + } + + 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)); + } + + public void triggerInlineAmortizationIfLoanClosed(final WorkingCapitalLoan loan, final LocalDate transactionDate) { + if ((loan.getLoanStatus().isClosed() || loan.getLoanStatus().isOverpaid()) + && loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount() + : null; + final boolean adjustmentNeeded = loan.getBalance() != null + && MathUtil.isGreaterThanZero(loan.getBalance().getRealizedIncomeFromDiscountFee()); + + if (MathUtil.isGreaterThanZero(discount) || adjustmentNeeded) { + discountFeeAmortizationService.processDiscountFeeAmortization(loan, transactionDate); + } + } + } +} 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 index 0191ab87431..d8868bcceda 100644 --- 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 @@ -27,7 +27,6 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest; @@ -65,7 +64,6 @@ public class WorkingCapitalLoanTransactionReprocessingServiceImpl implements Wor private final WorkingCapitalLoanAllocationRequestFactory allocationRequestFactory; private final WorkingCapitalLoanAllocationApplier allocationApplier; private final WorkingCapitalLoanBalanceUpdater balanceUpdater; - private final WorkingCapitalLoanChargePaymentHandler chargePaymentHandler; private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; @Override @@ -118,11 +116,8 @@ private void reprocessTransactions(final WorkingCapitalLoan loan, final List chargesById = charges.stream() .collect(Collectors.toMap(WorkingCapitalLoanCharge::getId, Function.identity())); - reapplyChargeAdjustments(allTransactions, chargesById, balance); // Re-allocate every non-reversed repayment-like transaction in chronological order. final List replayable = allTransactions.stream() @@ -176,21 +171,4 @@ private boolean isOverpaidByReplayableTransactions(final List 0; } - - private void reapplyChargeAdjustments(final List allTransactions, - final Map chargesById, final WorkingCapitalLoanBalance balance) { - for (final WorkingCapitalLoanTransaction txn : allTransactions) { - if (txn.isReversed() || txn.getTypeOf() != LoanTransactionType.CHARGE_ADJUSTMENT) { - continue; - } - txn.getLoanTransactionRelations().stream() - .filter(relation -> relation.getRelationType() == LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT - && relation.getToCharge() != null) - .findFirst().map(relation -> chargesById.get(relation.getToCharge().getId())).ifPresent(charge -> { - final BigDecimal amount = txn.getTransactionAmount(); - chargePaymentHandler.applyChargePayment(charge, amount); - balanceUpdater.applyChargePayment(balance, charge, amount); - }); - } - } } 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 a1af25784bc..b8d7828859d 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 @@ -61,8 +61,6 @@ import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; -import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan; -import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; @@ -115,14 +113,11 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; private final WorkingCapitalLoanTransactionReprocessingService transactionReprocessingService; private final WorkingCapitalLoanChargeRepository chargeRepository; - private final WorkingCapitalLoanPaymentAllocationProcessor allocationProcessor; private final WorkingCapitalLoanDelinquencyRangeScheduleService delinquencyRangeScheduleService; private final GlobalConfigurationRepositoryWrapper globalConfigurationRepository; private final WorkingCapitalLoanDelinquencyClassificationService delinquencyClassificationService; private final WorkingCapitalLoanBreachScheduleService breachScheduleService; - private final WorkingCapitalLoanAllocationRequestFactory allocationRequestFactory; - private final WorkingCapitalLoanAllocationApplier allocationApplier; - private final WorkingCapitalLoanBalanceUpdater balanceUpdater; + private final WorkingCapitalLoanTransactionProcessor transactionProcessor; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -592,8 +587,8 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); updateBalanceForDiscountChange(loan, amount, true); - handleStateChanges(loan, transactionDate); - triggerInlineAmortizationIfLoanClosed(loan, transactionDate); + stateMachine.determineAndTransition(loan, transactionDate); + transactionProcessor.triggerInlineAmortizationIfLoanClosed(loan, transactionDate); changes.put("status", loan.getLoanStatus()); loanRepository.save(loan); @@ -737,44 +732,9 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, transactionDate, classification, txnExternalId); this.transactionRepository.saveAndFlush(transaction); - final WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()) - .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - final List charges = this.chargeRepository.findByLoanIdAndActiveTrueOrderByDueDateAscIdAsc(loanId); - - // Decide the allocation across penalty/fee/principal following the loan's configured payment allocation order - // (principal-only when no order is configured), then materialize it onto the charges and refresh the balance. - final WorkingCapitalLoanAllocationRequest allocationRequest = allocationRequestFactory.build(loan, balance, charges, - transactionDate, transactionAmount, transactionType); - final WorkingCapitalLoanAllocationPlan allocationPlan = allocationProcessor.plan(allocationRequest); - final WorkingCapitalLoanTransactionAllocation allocation = allocationApplier.apply(transaction, null, allocationPlan, charges); - balanceUpdater.apply(balance, allocationPlan); - this.chargeRepository.saveAll(charges); - this.balanceRepository.saveAndFlush(balance); - this.allocationRepository.saveAndFlush(allocation); - - // 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, allocationPlan.principalPortion()); - } - if (backdated) { - transactionReprocessingService.reprocessTransactions(loan, allTransactions); - delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); - } else { - delinquencyRangeScheduleService.applyRepayment(loan, transactionDate, transactionAmount); - } - // Breach schedule is maintained incrementally here; reprocessing does not rebuild it. - breachScheduleService.applyRepayment(loanId, transactionDate, transactionAmount); + final WorkingCapitalLoanTransactionAllocation allocation = transactionProcessor.processRepaymentLikeTransaction(loan, transaction, + transactionType, transactionDate, transactionAmount); - handleStateChanges(loan, transactionDate); - triggerInlineAmortizationIfLoanClosed(loan, transactionDate); changes.put("status", loan.getLoanStatus()); handleNote(loan, command, changes); @@ -827,46 +787,6 @@ private void handleNote(WorkingCapitalLoan loan, JsonCommand command, Map 0) { - this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan); - if (loan.getMaturedOnDate() == null) { - loan.setMaturedOnDate(transactionDate); - } - } else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) { - if (!loan.getLoanStatus().isClosedObligationsMet()) { - this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan); - } - if (loan.getMaturedOnDate() == null) { - loan.setMaturedOnDate(transactionDate); - } - } else if (principalOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null) { - this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan); - loan.setMaturedOnDate(null); - } - } - } - @Override public CommandProcessingResult creditBalanceRefund(final Long loanId, final JsonCommand command) { final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) @@ -1043,7 +963,7 @@ public CommandProcessingResult undoTransaction(final WorkingCapitalLoan loan, fi accountingProcessor.postReversalJournalEntries(loan, transaction); } - handleStateChanges(loan, transaction.getReversedOnDate()); + stateMachine.determineAndTransition(loan, transaction.getReversedOnDate()); changes.put("status", loan.getLoanStatus()); handleNote(loan, command, changes); @@ -1132,15 +1052,6 @@ 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 markReversed(final WorkingCapitalLoanTransaction txn) { txn.setReversed(true); txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAllocationRequestFactoryTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAllocationRequestFactoryTest.java new file mode 100644 index 00000000000..1e72d33ddd9 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAllocationRequestFactoryTest.java @@ -0,0 +1,103 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPaymentAllocationRule; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType; +import org.junit.jupiter.api.Test; + +class WorkingCapitalLoanAllocationRequestFactoryTest { + + private static final LocalDate TXN_DATE = LocalDate.of(2026, 6, 19); + + private final WorkingCapitalLoanAllocationRequestFactory factory = new WorkingCapitalLoanAllocationRequestFactory(); + + @Test + void resolvesTheRuleConfiguredForTheGivenTransactionTypeRatherThanAlwaysUsingRepayment() { + final WorkingCapitalLoanPaymentAllocationRule repaymentRule = rule(PaymentAllocationTransactionType.REPAYMENT, + WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL); + final WorkingCapitalLoanPaymentAllocationRule goodwillCreditRule = rule(PaymentAllocationTransactionType.GOODWILL_CREDIT, + WorkingCapitalPaymentAllocationType.DUE_PENALTY, WorkingCapitalPaymentAllocationType.DUE_FEE); + final WorkingCapitalLoan loan = loanWithRules(repaymentRule, goodwillCreditRule); + + final WorkingCapitalLoanAllocationRequest request = factory.build(loan, balance("100"), List.of(), TXN_DATE, new BigDecimal("50"), + LoanTransactionType.GOODWILL_CREDIT); + + assertEquals(List.of(WorkingCapitalPaymentAllocationType.DUE_PENALTY, WorkingCapitalPaymentAllocationType.DUE_FEE), + request.allocationOrder()); + } + + @Test + void fallsBackToDefaultRuleWhenNoTransactionSpecificRuleIsConfigured() { + final WorkingCapitalLoanPaymentAllocationRule repaymentRule = rule(PaymentAllocationTransactionType.REPAYMENT, + WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL); + final WorkingCapitalLoanPaymentAllocationRule defaultRule = rule(PaymentAllocationTransactionType.DEFAULT, + WorkingCapitalPaymentAllocationType.DUE_FEE, WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL); + final WorkingCapitalLoan loan = loanWithRules(repaymentRule, defaultRule); + + final WorkingCapitalLoanAllocationRequest request = factory.build(loan, balance("100"), List.of(), TXN_DATE, new BigDecimal("50"), + LoanTransactionType.GOODWILL_CREDIT); + + // Must fall back to DEFAULT, not silently reuse the REPAYMENT rule. + assertEquals(List.of(WorkingCapitalPaymentAllocationType.DUE_FEE, WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL), + request.allocationOrder()); + } + + @Test + void repaymentStillResolvesItsOwnRule() { + final WorkingCapitalLoanPaymentAllocationRule repaymentRule = rule(PaymentAllocationTransactionType.REPAYMENT, + WorkingCapitalPaymentAllocationType.DUE_PENALTY, WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL); + final WorkingCapitalLoan loan = loanWithRules(repaymentRule); + + final WorkingCapitalLoanAllocationRequest request = factory.build(loan, balance("100"), List.of(), TXN_DATE, new BigDecimal("50"), + LoanTransactionType.REPAYMENT); + + assertEquals(List.of(WorkingCapitalPaymentAllocationType.DUE_PENALTY, WorkingCapitalPaymentAllocationType.DUE_PRINCIPAL), + request.allocationOrder()); + } + + private WorkingCapitalLoanPaymentAllocationRule rule(final PaymentAllocationTransactionType transactionType, + final WorkingCapitalPaymentAllocationType... allocationTypes) { + return new WorkingCapitalLoanPaymentAllocationRule(null, transactionType, List.of(allocationTypes)); + } + + private WorkingCapitalLoan loanWithRules(final WorkingCapitalLoanPaymentAllocationRule... rules) { + final WorkingCapitalLoan loan = mock(WorkingCapitalLoan.class); + when(loan.getPaymentAllocationRules()).thenReturn(List.of(rules)); + return loan; + } + + private WorkingCapitalLoanBalance balance(final String principalOutstanding) { + final WorkingCapitalLoanBalance balance = mock(WorkingCapitalLoanBalance.class); + when(balance.getPrincipalOutstanding()).thenReturn(new BigDecimal(principalOutstanding)); + return balance; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java index 2b17bff29f1..d0f6aeddefc 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java @@ -40,6 +40,7 @@ import org.apache.fineract.client.models.PostLoansLoanIdChargesRequest; import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdChargesChargeIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdResponse; @@ -131,6 +132,12 @@ public Long undoTransaction(Long loanId, Long transactionId, ExecuteWorkingCapit return response.getResourceId(); } + public Long makeGoodwillCredit(Long loanId, PostWorkingCapitalLoanTransactionsRequest request) { + PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionById(loanId, "goodwillCredit", request)); + return response.getResourceId(); + } + public List getTransactions(Long loanId) { GetWorkingCapitalLoanTransactionsResponse response = ok( () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java index bc1ca998337..7394fafa5e6 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java @@ -98,6 +98,11 @@ public static PostWorkingCapitalLoanTransactionsRequest repayment(BigDecimal amo .dateFormat(DATE_FORMAT); } + public static PostWorkingCapitalLoanTransactionsRequest goodwillCredit(BigDecimal amount, String transactionDate) { + return new PostWorkingCapitalLoanTransactionsRequest().transactionAmount(amount).transactionDate(transactionDate).locale(LOCALE) + .dateFormat(DATE_FORMAT); + } + public static ExecuteWorkingCapitalLoanTransactionCommandRequest undoTransaction() { return new ExecuteWorkingCapitalLoanTransactionCommandRequest(); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java index d1a039875be..8767ee5dc97 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java @@ -21,6 +21,7 @@ import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.assertEqualBigDecimal; import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.findCharge; import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.findTransaction; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java new file mode 100644 index 00000000000..56a25591b95 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java @@ -0,0 +1,265 @@ +/** + * 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.time.Month; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.GetBalance; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.PostPaymentAllocation; +import org.apache.fineract.client.models.WorkingCapitalLoanChargeData; +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.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +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; + +public class FeignWorkingCapitalLoanPaymentAllocationRuleTest extends FeignIntegrationTest { + + // Fee/penalty-before-principal — the product-level DEFAULT_PAYMENT_ALLOCATION_TYPES order. + private static final List FEE_BEFORE_PRINCIPAL_ORDER = List.of("DUE_PENALTY", "DUE_FEE", "DUE_PRINCIPAL", "IN_ADVANCE_PENALTY", + "IN_ADVANCE_FEE", "IN_ADVANCE_PRINCIPAL"); + // Principal-before-fee/penalty — a custom order only configured for GOODWILL_CREDIT. + private static final List PRINCIPAL_BEFORE_FEE_ORDER = List.of("DUE_PRINCIPAL", "DUE_PENALTY", "DUE_FEE", + "IN_ADVANCE_PRINCIPAL", "IN_ADVANCE_PENALTY", "IN_ADVANCE_FEE"); + + 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() { + for (final Long loanId : createdLoanIds) { + if (loanId == null) { + continue; + } + try { + wcLoanHelper.undoDisbursal(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseRequest()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + try { + wcLoanHelper.undoApproval(loanId, WorkingCapitalLoanApplicationTestBuilder.buildUndoApproveRequest()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + try { + wcLoanHelper.delete(loanId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + } + createdLoanIds.clear(); + for (final Long productId : createdProductIds) { + if (productId == null) { + continue; + } + try { + productHelper.deleteWorkingCapitalLoanProductById(productId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup + } + } + createdProductIds.clear(); + } + + @Test + void testGoodwillCreditUsesItsOwnConfiguredAllocationOrderInsteadOfRepaymentOrder() { + final Long[] loanIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + businessDateHelper.runAt("2026-02-01", () -> { + Long client = clientHelper.createClient("01 February 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 February 2026"); + feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 100, "01 February 2026"); + }); + businessDateHelper.runAt("2026-02-10", () -> { + // REPAYMENT follows the product's DEFAULT order (fee before principal): a 60 repayment is fully + // absorbed by the 100 fee, leaving principal untouched. + + wcLoanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(60), "10 February 2026")); + + GetWorkingCapitalLoanTransactionIdResponse repayment = findTransaction(wcLoanHelper.getTransactions(loanIdHolder[0]), + "loanTransactionType.repayment", LocalDate.of(2026, Month.FEBRUARY, 10), BigDecimal.valueOf(60)); + assertEqualBigDecimal(BigDecimal.valueOf(60), repayment.getFeeChargesPortion()); + assertEqualBigDecimal(BigDecimal.ZERO, repayment.getPrincipalPortion()); + + GetBalance afterRepayment = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.valueOf(60), afterRepayment.getFeePaid()); + assertEqualBigDecimal(BigDecimal.ZERO, afterRepayment.getPrincipalPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(9000), afterRepayment.getPrincipalOutstanding()); + }); + businessDateHelper.runAt("2026-02-15", () -> { + // GOODWILL_CREDIT follows its own configured order (principal before fee/penalty): an identical 60 + // amount is instead fully absorbed by principal, leaving the remaining 40 fee outstanding untouched. + + wcLoanHelper.makeGoodwillCredit(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.goodwillCredit(BigDecimal.valueOf(60), "15 February 2026")); + + GetWorkingCapitalLoanTransactionIdResponse goodwillCredit = findTransaction(wcLoanHelper.getTransactions(loanIdHolder[0]), + "loanTransactionType.goodwillCredit", LocalDate.of(2026, Month.FEBRUARY, 15), BigDecimal.valueOf(60)); + assertEqualBigDecimal(BigDecimal.valueOf(60), goodwillCredit.getPrincipalPortion()); + assertEqualBigDecimal(BigDecimal.ZERO, goodwillCredit.getFeeChargesPortion()); + + GetBalance afterGoodwillCredit = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.valueOf(60), afterGoodwillCredit.getPrincipalPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(8940), afterGoodwillCredit.getPrincipalOutstanding()); + assertEqualBigDecimal(BigDecimal.valueOf(60), afterGoodwillCredit.getFeePaid()); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.valueOf(60), feeCharge.getAmountPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(40), feeCharge.getAmountOutstanding()); + }); + } + + @Test + void testChargeAdjustmentUsesItsOwnConfiguredAllocationOrderInsteadOfDefaultOrder() { + final Long[] loanIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + businessDateHelper.runAt("2026-03-01", () -> { + Long client = clientHelper.createClient("01 March 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 March 2026", + createProductWithChargeAdjustmentOverride()); + feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 100, "01 March 2026"); + }); + businessDateHelper.runAt("2026-03-05", () -> { + wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(60), "05 March 2026")); + + GetWorkingCapitalLoanTransactionIdResponse chargeAdjustment = findTransaction(wcLoanHelper.getTransactions(loanIdHolder[0]), + "loanTransactionType.chargeAdjustment", LocalDate.of(2026, Month.MARCH, 5), BigDecimal.valueOf(60)); + assertEqualBigDecimal(BigDecimal.valueOf(60), chargeAdjustment.getPrincipalPortion()); + assertEqualBigDecimal(BigDecimal.ZERO, chargeAdjustment.getFeeChargesPortion()); + + GetBalance balance = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.valueOf(60), balance.getPrincipalPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(8940), balance.getPrincipalOutstanding()); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getFeePaid()); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, feeCharge.getAmountPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(100), feeCharge.getAmountOutstanding()); + }); + } + + private Long addFeeCharge(Long loanId, double amount, String dueDate) { + Long chargeId = wcLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, amount)); + return wcLoanHelper.addCharge(loanId, WorkingCapitalLoanRequestBuilders.addCharge(chargeId, amount, dueDate)); + } + + private GetBalance balanceOf(Long loanId) { + GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertNotNull(loan.getBalance(), "Balance should exist"); + return loan.getBalance(); + } + + private Long createAndDisburseLoanOnDate(Long clientIdParam, BigDecimal principal, String date) { + return createAndDisburseLoanOnDate(clientIdParam, principal, date, createProductWithGoodwillCreditOverride()); + } + + private Long createAndDisburseLoanOnDate(Long clientIdParam, BigDecimal principal, String date, Long productId) { + 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 createProductWithGoodwillCreditOverride() { + String uniqueName = "WCL PayAlloc " + Utils.uniqueRandomStringGenerator("", 8); + String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName) + .withPaymentAllocationTypes(FEE_BEFORE_PRINCIPAL_ORDER).withPaymentAllocationForTransactionType( + PostPaymentAllocation.TransactionTypeEnum.GOODWILL_CREDIT, PRINCIPAL_BEFORE_FEE_ORDER) + .build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createProductWithChargeAdjustmentOverride() { + String uniqueName = "WCL PayAlloc " + Utils.uniqueRandomStringGenerator("", 8); + String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName) + .withPaymentAllocationTypes(FEE_BEFORE_PRINCIPAL_ORDER).withPaymentAllocationForTransactionType( + PostPaymentAllocation.TransactionTypeEnum.CHARGE_ADJUSTMENT, PRINCIPAL_BEFORE_FEE_ORDER) + .build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private static GetWorkingCapitalLoanTransactionIdResponse findTransaction(List transactions, + String transactionCode, LocalDate transactionDate, BigDecimal amount) { + return transactions.stream().filter(txn -> transactionDate.equals(txn.getTransactionDate())) + .filter(txn -> Objects.nonNull(txn.getType()) && StringUtils.equals(transactionCode, txn.getType().getCode())) + .filter(txn -> txn.getTransactionAmount() != null && amount.compareTo(txn.getTransactionAmount()) == 0).findFirst() + .orElseThrow(() -> new AssertionError("Transaction not found on " + transactionDate + " with amount " + amount)); + } + + private static WorkingCapitalLoanChargeData findCharge(List charges, Long loanChargeId) { + return charges.stream().filter(charge -> loanChargeId.equals(charge.getId())).findFirst() + .orElseThrow(() -> new AssertionError("Loan charge not found with id " + loanChargeId)); + } + + private static void assertEqualBigDecimal(BigDecimal expected, BigDecimal actual) { + assertNotNull(actual); + assertEquals(0, expected.compareTo(actual)); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java index ba5af379e47..26a4a950cb3 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java @@ -19,6 +19,7 @@ package org.apache.fineract.integrationtests.common.workingcapitalloanproduct; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.IntStream; @@ -74,6 +75,7 @@ public class WorkingCapitalLoanProductTestBuilder { private String repaymentFrequencyType = DEFAULT_PERIOD_PAYMENT_FREQUENCY_TYPE; private Long breachId; private List paymentAllocationTypes = DEFAULT_PAYMENT_ALLOCATION_TYPES; + private final List additionalPaymentAllocations = new ArrayList<>(); private Map allowAttributeOverrides; private Integer delinquencyGraceDays; private String delinquencyStartType; @@ -155,6 +157,17 @@ public WorkingCapitalLoanProductTestBuilder withPaymentAllocationTypes(final Lis return this; } + /** + * Adds a payment allocation rule scoped to a specific transaction type (e.g. GOODWILL_CREDIT, CHARGE_ADJUSTMENT), + * on top of the DEFAULT rule. {@code allocationOrder} must contain every + * {@link org.apache.fineract.client.models.PaymentAllocationOrder} rule exactly once. + */ + public WorkingCapitalLoanProductTestBuilder withPaymentAllocationForTransactionType( + final PostPaymentAllocation.TransactionTypeEnum transactionType, final List allocationOrder) { + this.additionalPaymentAllocations.add(buildPaymentAllocationEntry(transactionType, allocationOrder)); + return this; + } + public WorkingCapitalLoanProductTestBuilder withPrincipalAmountMin(final BigDecimal principalAmountMin) { this.principalAmountMin = principalAmountMin; return this; @@ -397,31 +410,39 @@ private void populateCommonFields(final PutWorkingCapitalLoanProductsProductIdRe } private void setPaymentAllocation(final PostWorkingCapitalLoanProductsRequest request) { - if (this.paymentAllocationTypes != null && !this.paymentAllocationTypes.isEmpty()) { - PostPaymentAllocation defaultPaymentAllocation = new PostPaymentAllocation(); - defaultPaymentAllocation.setTransactionType(PostPaymentAllocation.TransactionTypeEnum.DEFAULT); - defaultPaymentAllocation.setPaymentAllocationOrder(IntStream.range(0, this.paymentAllocationTypes.size()).mapToObj(index -> { - PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); - paymentAllocationOrder.setOrder(index + 1); - paymentAllocationOrder.setPaymentAllocationRule(this.paymentAllocationTypes.get(index)); - return paymentAllocationOrder; - }).toList()); - request.setPaymentAllocation(List.of(defaultPaymentAllocation)); + final List allocations = buildPaymentAllocations(); + if (!allocations.isEmpty()) { + request.setPaymentAllocation(allocations); } } private void setPaymentAllocation(final PutWorkingCapitalLoanProductsProductIdRequest request) { + final List allocations = buildPaymentAllocations(); + if (!allocations.isEmpty()) { + request.setPaymentAllocation(allocations); + } + } + + private List buildPaymentAllocations() { + final List allocations = new ArrayList<>(); if (this.paymentAllocationTypes != null && !this.paymentAllocationTypes.isEmpty()) { - PostPaymentAllocation defaultPaymentAllocation = new PostPaymentAllocation(); - defaultPaymentAllocation.setTransactionType(PostPaymentAllocation.TransactionTypeEnum.DEFAULT); - defaultPaymentAllocation.setPaymentAllocationOrder(IntStream.range(0, this.paymentAllocationTypes.size()).mapToObj(index -> { - PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); - paymentAllocationOrder.setOrder(index + 1); - paymentAllocationOrder.setPaymentAllocationRule(this.paymentAllocationTypes.get(index)); - return paymentAllocationOrder; - }).toList()); - request.setPaymentAllocation(List.of(defaultPaymentAllocation)); + allocations.add(buildPaymentAllocationEntry(PostPaymentAllocation.TransactionTypeEnum.DEFAULT, this.paymentAllocationTypes)); } + allocations.addAll(this.additionalPaymentAllocations); + return allocations; + } + + private static PostPaymentAllocation buildPaymentAllocationEntry(final PostPaymentAllocation.TransactionTypeEnum transactionType, + final List allocationOrder) { + final PostPaymentAllocation paymentAllocation = new PostPaymentAllocation(); + paymentAllocation.setTransactionType(transactionType); + paymentAllocation.setPaymentAllocationOrder(IntStream.range(0, allocationOrder.size()).mapToObj(index -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setOrder(index + 1); + paymentAllocationOrder.setPaymentAllocationRule(allocationOrder.get(index)); + return paymentAllocationOrder; + }).toList()); + return paymentAllocation; } private void setAllowAttributeOverrides(final PostWorkingCapitalLoanProductsRequest request) { From c9b81f240cbc3df476b21b9864c94e0aba88fa61 Mon Sep 17 00:00:00 2001 From: "mark.vituska" Date: Wed, 8 Jul 2026 11:41:25 +0200 Subject: [PATCH 2/3] FINERACT-2455: Add unified charge adjustment undo --- ...WorkingCapitalLoanChargeAdjustment.feature | 15 +- ...gCapitalLoanChargesApiResourceSwagger.java | 6 +- ...rkingCapitalLoanLifecycleStateMachine.java | 9 +- ...WorkingCapitalLoanChargeDataValidator.java | 21 +-- ...talLoanChargeWritePlatformServiceImpl.java | 7 +- ...ngCapitalLoanWritePlatformServiceImpl.java | 50 +----- ...ingCapitalLoanChargeDataValidatorTest.java | 88 +++++++++++ .../FeignWorkingCapitalLoanHelper.java | 12 +- .../WorkingCapitalLoanRequestBuilders.java | 5 +- ...orkingCapitalLoanChargeAllocationTest.java | 138 +++++++++++++++- ...gCapitalLoanPaymentAllocationRuleTest.java | 148 +++++++++++++++++- ...apitalLoanReprocessingCorrectnessTest.java | 3 +- 12 files changed, 410 insertions(+), 92 deletions(-) create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidatorTest.java diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature index 96e8f06f847..28673b9abea 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature @@ -159,8 +159,8 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature Scenario: Verify Working Capital charge adjustment - UC7: fee and penalty charges adjusted independently Given Admin sets the business date to "01 January 2026" And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: - | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | - | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DUE_FEE_PENALTY_PRINCIPAL | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | 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 80.0 transaction amount @@ -184,8 +184,8 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature Scenario: Verify Working Capital charge adjustment - UC8: fee and penalty charges adjusted independently on different days Given Admin sets the business date to "01 January 2026" And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: - | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | - | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DUE_FEE_PENALTY_PRINCIPAL | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | 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 80.0 transaction amount @@ -301,10 +301,11 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature When Admin makes a charge adjustment for the last added charge with 100.0 amount on working capital loan And Admin reverts the last charge adjustment on working capital loan Then Reverting an already reversed charge adjustment on working capital loan results an error with the following data: - | httpCode | errorMessage | - | 400 | Charge adjustment transaction is already reversed | + | httpCode | errorMessage | + | 400 | Failed data validation due to: transaction.already.undone | - @TestRailId:C85233 + #TODO should this be removed? + @Skip @TestRailId:C85233 Scenario: Verify Working Capital charge adjustment - UC13: charge adjustment with explicit transaction date Given Admin sets the business date to "01 January 2026" And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResourceSwagger.java index ad47faca034..f70462dadb3 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResourceSwagger.java @@ -73,8 +73,6 @@ private PostWorkingCapitalLoansLoanIdChargesChargeIdRequest() {} public String locale; @Schema(example = "dd MMMM yyyy") public String dateFormat; - @Schema(example = "29 April 2013") - public String transactionDate; @Schema(example = "786444UUUYYH7") public String externalId; @Schema(example = "some note") @@ -97,9 +95,9 @@ private PostWorkingCapitalLoansLoanIdChargesChargeIdResponse() {} @Schema(example = "95174ff9-1a75-4d72-a413-6f9b1cb988b7") public String resourceExternalId; @Schema(example = "12") - public Long subEntityId; + public Long subResourceId; @Schema(example = "a1b2c3d4-0000-0000-0000-000000000000") - public String subEntityExternalId; + public String subResourceExternalId; } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java index fc4d43c672e..00df7890040 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java @@ -51,15 +51,20 @@ public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDat if (loan.getBalance() == null) { return; } + final LoanStatus currentStatus = loan.getLoanStatus(); final BigDecimal overpaymentAmount = MathUtil.nullToZero(loan.getBalance().getOverpaymentAmount()); final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding()); if (overpaymentAmount.compareTo(BigDecimal.ZERO) > 0) { - transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan); + if (currentStatus == null || !currentStatus.isOverpaid()) { + transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan); + } if (loan.getMaturedOnDate() == null) { loan.setMaturedOnDate(transactionDate); } } else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) { - transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan); + if (currentStatus == null || !currentStatus.isClosedObligationsMet()) { + transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan); + } if (loan.getMaturedOnDate() == null) { loan.setMaturedOnDate(transactionDate); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java index 9cb0acfc989..2ae0e62b16c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidator.java @@ -49,13 +49,13 @@ public void validateChargeAdjustmentRequest(final String json) { throw new InvalidJsonException(); } - final Set allowedParameters = new HashSet<>(Arrays.asList(WorkingCapitalLoanChargeConstants.amountParamName, - WorkingCapitalLoanChargeConstants.transactionDateParamName, WorkingCapitalLoanChargeConstants.externalIdParamName, - WorkingCapitalLoanChargeConstants.localeParamName, WorkingCapitalLoanChargeConstants.dateFormatParamName, - WorkingCapitalLoanChargeConstants.noteParamName, WorkingCapitalLoanChargeConstants.paymentDetailsParamName, - WorkingCapitalLoanChargeConstants.paymentTypeIdParamName, WorkingCapitalLoanChargeConstants.accountNumberParamName, - WorkingCapitalLoanChargeConstants.checkNumberParamName, WorkingCapitalLoanChargeConstants.routingCodeParamName, - WorkingCapitalLoanChargeConstants.receiptNumberParamName, WorkingCapitalLoanChargeConstants.bankNumberParamName)); + final Set allowedParameters = new HashSet<>( + Arrays.asList(WorkingCapitalLoanChargeConstants.amountParamName, WorkingCapitalLoanChargeConstants.externalIdParamName, + WorkingCapitalLoanChargeConstants.localeParamName, WorkingCapitalLoanChargeConstants.dateFormatParamName, + WorkingCapitalLoanChargeConstants.noteParamName, WorkingCapitalLoanChargeConstants.paymentDetailsParamName, + WorkingCapitalLoanChargeConstants.paymentTypeIdParamName, WorkingCapitalLoanChargeConstants.accountNumberParamName, + WorkingCapitalLoanChargeConstants.checkNumberParamName, WorkingCapitalLoanChargeConstants.routingCodeParamName, + WorkingCapitalLoanChargeConstants.receiptNumberParamName, WorkingCapitalLoanChargeConstants.bankNumberParamName)); final Type typeOfMap = new TypeToken>() {}.getType(); fromJsonHelper.checkForUnsupportedParameters(typeOfMap, json, allowedParameters); @@ -70,13 +70,6 @@ public void validateChargeAdjustmentRequest(final String json) { element); baseDataValidator.reset().parameter(WorkingCapitalLoanChargeConstants.amountParamName).value(amount).notNull().positiveAmount(); - if (this.fromJsonHelper.parameterExists(WorkingCapitalLoanChargeConstants.transactionDateParamName, element)) { - final LocalDate transactionDate = this.fromJsonHelper - .extractLocalDateNamed(WorkingCapitalLoanChargeConstants.transactionDateParamName, element); - baseDataValidator.reset().parameter(WorkingCapitalLoanChargeConstants.transactionDateParamName).value(transactionDate) - .notBlank(); - } - throwExceptionIfValidationWarningsExist(dataValidationErrors); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java index 32e93bdff8c..80efdec9fc1 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java @@ -124,7 +124,7 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final } final BigDecimal amount = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanChargeConstants.amountParamName); - final LocalDate transactionDate = resolveTransactionDate(command); + final LocalDate transactionDate = ThreadLocalContextUtil.getBusinessDate(); final ExternalId externalId = externalIdFactory.createFromCommand(command, WorkingCapitalLoanChargeConstants.externalIdParamName); chargeAdjustmentEntranceValidation(loan, wcCharge, amount); @@ -176,11 +176,6 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final .build(); } - private LocalDate resolveTransactionDate(final JsonCommand command) { - final LocalDate requested = command.localDateValueOfParameterNamed(WorkingCapitalLoanChargeConstants.transactionDateParamName); - return requested != null ? requested : ThreadLocalContextUtil.getBusinessDate(); - } - private void chargeAdjustmentEntranceValidation(final WorkingCapitalLoan loan, final WorkingCapitalLoanCharge wcCharge, final BigDecimal amount) { if (loan.getLoanStatus() != LoanStatus.ACTIVE && loan.getLoanStatus() != LoanStatus.CLOSED_OBLIGATIONS_MET 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 b8d7828859d..ba96046ca8e 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 @@ -619,61 +619,13 @@ public CommandProcessingResult undoTransaction(final Long loanId, final Long tra "Working capital loan transaction not found", WorkingCapitalLoanConstants.transactionIdParamName)); return switch (transaction.getTypeOf()) { case DISCOUNT_FEE_ADJUSTMENT -> undoDiscountFeeAdjustment(loan, transaction, command); - case CHARGE_ADJUSTMENT -> undoChargeAdjustment(loan, transaction, command); - case REPAYMENT, GOODWILL_CREDIT -> undoTransaction(loan, transaction, command); + case REPAYMENT, GOODWILL_CREDIT, CHARGE_ADJUSTMENT -> undoTransaction(loan, transaction, command); default -> throw new PlatformApiDataValidationException("validation.msg.wc.loan.transaction.undo.not.supported", "Undo is not supported for transaction type " + transaction.getTypeOf(), WorkingCapitalLoanConstants.transactionTypeParamName); }; } - private CommandProcessingResult undoChargeAdjustment(final WorkingCapitalLoan loan, - final WorkingCapitalLoanTransaction adjustmentTransaction, final JsonCommand command) { - if (adjustmentTransaction.isReversed()) { - throw new PlatformApiDataValidationException("validation.msg.wc.loan.charge.adjustment.already.reversed", - "Charge adjustment transaction is already reversed", WorkingCapitalLoanConstants.transactionIdParamName); - } - - final WorkingCapitalLoanTransactionRelation chargeRelation = adjustmentTransaction.getLoanTransactionRelations().stream() - .filter(r -> r.getToCharge() != null && r.getRelationType() == LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT) - .findFirst() - .orElseThrow(() -> new PlatformApiDataValidationException("validation.msg.wc.loan.charge.adjustment.relation.missing", - "Charge adjustment transaction is missing the link to the charge", - WorkingCapitalLoanConstants.transactionIdParamName)); - - final org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge wcCharge = chargeRelation.getToCharge(); - final BigDecimal amount = adjustmentTransaction.getTransactionAmount(); - - reverseTransaction(adjustmentTransaction); - accountingProcessor.postReversalJournalEntries(loan, adjustmentTransaction); - - final BigDecimal newPaid = MathUtil.subtract(MathUtil.nullToZero(wcCharge.getAmountPaid()), amount).max(BigDecimal.ZERO); - wcCharge.setAmountPaid(newPaid); - if (newPaid.compareTo(MathUtil.nullToZero(wcCharge.getAmount())) < 0) { - wcCharge.setPaid(false); - } - - final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()) - .orElseGet(() -> WorkingCapitalLoanBalance.createFor(loan)); - if (wcCharge.isPenaltyCharge()) { - balance.setPenaltyPaid(MathUtil.subtract(MathUtil.nullToZero(balance.getPenaltyPaid()), amount).max(BigDecimal.ZERO)); - } else { - balance.setFeePaid(MathUtil.subtract(MathUtil.nullToZero(balance.getFeePaid()), amount).max(BigDecimal.ZERO)); - } - balanceRepository.save(balance); - - final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); - createNote(noteText, loan); - - final Map changes = new LinkedHashMap<>(); - if (StringUtils.isNotBlank(noteText)) { - changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); - } - return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(adjustmentTransaction.getId()) - .withEntityExternalId(adjustmentTransaction.getExternalId()).withOfficeId(loan.getOfficeId()) - .withClientId(loan.getClientId()).withLoanId(loan.getId()).with(changes).build(); - } - private CommandProcessingResult undoDiscountFeeAdjustment(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction adjustmentTransaction, final JsonCommand command) { validator.validateUndoDiscountAdjustmentTransaction(loan, adjustmentTransaction); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidatorTest.java new file mode 100644 index 00000000000..bb9de6d1f2e --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeDataValidatorTest.java @@ -0,0 +1,88 @@ +/** + * 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.serialization; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.gson.JsonObject; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.domain.ActionContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class WorkingCapitalLoanChargeDataValidatorTest { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd MMMM yyyy"); + + private WorkingCapitalLoanChargeDataValidator validator; + private LocalDate businessDate; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + businessDate = LocalDate.now(ZoneId.systemDefault()); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, businessDate))); + + validator = new WorkingCapitalLoanChargeDataValidator(new FromJsonHelper()); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + void shouldThrowInvalidJsonWhenJsonIsBlank() { + assertThrows(InvalidJsonException.class, () -> validator.validateChargeAdjustmentRequest("")); + } + + @Test + void shouldAcceptRequestWithoutTransactionDate() { + final JsonObject json = baseRequest(); + assertDoesNotThrow(() -> validator.validateChargeAdjustmentRequest(json.toString())); + } + + @Test + void shouldRejectRequestContainingTransactionDate() { + final JsonObject json = baseRequest(); + json.addProperty("transactionDate", DATE_FORMAT.format(businessDate)); + assertThrows(UnsupportedParameterException.class, () -> validator.validateChargeAdjustmentRequest(json.toString())); + } + + private JsonObject baseRequest() { + final JsonObject json = new JsonObject(); + json.addProperty("locale", "en"); + json.addProperty("dateFormat", "dd MMMM yyyy"); + json.addProperty("amount", "40"); + return json; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java index d0f6aeddefc..ce9627435e2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignWorkingCapitalLoanHelper.java @@ -42,6 +42,7 @@ import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdChargesChargeIdRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdChargesChargeIdResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; @@ -166,8 +167,15 @@ public List getBreachSchedule(Long loanId) return ok(() -> fineractClient.workingCapitalLoanBreachSchedule().retrieveBreachSchedule(loanId)); } - public void adjustCharge(Long loanId, Long loanChargeId, PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request) { - ok(() -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "adjustment")); + public Long adjustCharge(Long loanId, Long loanChargeId, PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request) { + PostWorkingCapitalLoansLoanIdChargesChargeIdResponse response = ok( + () -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "adjustment")); + return response.getSubResourceId(); + } + + public void undoLoanTransaction(Long loanId, Long transactionId, ExecuteWorkingCapitalLoanTransactionCommandRequest request) { + ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId(loanId, + transactionId, "undo", request)); } /** diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java index 7394fafa5e6..7e097681b83 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java @@ -119,8 +119,7 @@ public static PostLoansLoanIdChargesRequest addCharge(Long chargeId, double amou .dateFormat(DATE_FORMAT); } - public static PostWorkingCapitalLoansLoanIdChargesChargeIdRequest chargeAdjustment(BigDecimal amount, String transactionDate) { - return new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest().amount(amount).transactionDate(transactionDate).locale(LOCALE) - .dateFormat(DATE_FORMAT); + public static PostWorkingCapitalLoansLoanIdChargesChargeIdRequest chargeAdjustment(BigDecimal amount) { + return new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest().amount(amount).locale(LOCALE).dateFormat(DATE_FORMAT); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java index 8767ee5dc97..668dbf0d143 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanChargeAllocationTest.java @@ -23,13 +23,16 @@ import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.findTransaction; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.GetBalance; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; @@ -172,8 +175,7 @@ void testChargeAdjustmentPartiallySettlesChargeAndRefreshesBalanceBucket() { // A 40 charge adjustment partially settles the 100 fee charge and bumps the fee bucket by 40. businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-05"); - wcLoanHelper.adjustCharge(loanId, feeLoanChargeId, - WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40), "05 January 2026")); + wcLoanHelper.adjustCharge(loanId, feeLoanChargeId, WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanId), feeLoanChargeId); assertEqualBigDecimal(BigDecimal.valueOf(40), feeCharge.getAmountPaid(), "Fee charge amount paid should be 40"); @@ -187,6 +189,138 @@ void testChargeAdjustmentPartiallySettlesChargeAndRefreshesBalanceBucket() { }); } + @Test + void testUndoChargeAdjustmentRestoresChargeAndBalance() { + final Long[] loanIdHolder = new Long[1]; + final Long[] adjustmentTransactionIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + businessDateHelper.runAt("2026-01-01", () -> { + Long client = clientHelper.createClient("01 January 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 January 2026"); + feeLoanChargeIdHolder[0] = addCharge(loanIdHolder[0], false, 100, "01 January 2026"); + + businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-05"); + adjustmentTransactionIdHolder[0] = wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); + }); + businessDateHelper.runAt("2026-01-10", () -> { + wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction()); + + GetWorkingCapitalLoanTransactionIdResponse reversedAdjustment = wcLoanHelper.getTransactions(loanIdHolder[0]).stream() + .filter(txn -> adjustmentTransactionIdHolder[0].equals(txn.getId())).findFirst() + .orElseThrow(() -> new AssertionError("Charge adjustment transaction not found")); + assertEquals(Boolean.TRUE, reversedAdjustment.getReversed(), "Charge adjustment transaction should be flagged reversed"); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, feeCharge.getAmountPaid(), "Fee charge amount paid should be restored to 0"); + assertEqualBigDecimal(BigDecimal.valueOf(100), feeCharge.getAmountOutstanding(), + "Fee charge outstanding should be restored to 100"); + assertNotEquals(Boolean.TRUE, feeCharge.getPaid(), "Fee charge should no longer be flagged paid"); + + GetBalance balance = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getFeePaid(), "Fee paid bucket should be restored to 0"); + assertEqualBigDecimal(BigDecimal.valueOf(100), balance.getFeeOutstanding(), "Fee outstanding should be restored to 100"); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getPrincipalPaid(), "Principal bucket remains untouched"); + }); + } + + @Test + void testUndoAlreadyReversedChargeAdjustmentFails() { + final Long[] loanIdHolder = new Long[1]; + final Long[] adjustmentTransactionIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + businessDateHelper.runAt("2026-01-01", () -> { + Long client = clientHelper.createClient("01 January 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 January 2026"); + feeLoanChargeIdHolder[0] = addCharge(loanIdHolder[0], false, 100, "01 January 2026"); + }); + businessDateHelper.runAt("2026-01-05", () -> { + adjustmentTransactionIdHolder[0] = wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); + }); + businessDateHelper.runAt("2026-01-10", () -> { + wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction()); + + final CallFailedRuntimeException ex = assertThrows(CallFailedRuntimeException.class, + () -> wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction())); + assertEquals(400, ex.getStatus(), "Undoing an already-reversed charge adjustment must fail"); + }); + } + + @Test + void testUndoChargeAdjustmentIsAllowedWhileLoanIsClosed() { + final Long[] loanIdHolder = new Long[1]; + final Long[] adjustmentTransactionIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + businessDateHelper.runAt("2026-01-01", () -> { + Long client = clientHelper.createClient("01 January 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 January 2026"); + // Due well after the repayment below, so it's an "in advance" charge and doesn't compete with + // principal in the repayment that closes the loan. + feeLoanChargeIdHolder[0] = addCharge(loanIdHolder[0], false, 100, "31 January 2026"); + }); + businessDateHelper.runAt("2026-01-02", () -> { + wcLoanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(9000), "02 January 2026")); + GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.getLoanDetails(loanIdHolder[0]); + assertNotNull(loanDetails.getStatus()); + assertEquals("loanStatusType.closed.obligations.met", loanDetails.getStatus().getCode(), + "Loan should be fully closed after the principal-only repayment"); + + adjustmentTransactionIdHolder[0] = wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); + }); + businessDateHelper.runAt("2026-01-03", () -> { + wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction()); + + List transactions = wcLoanHelper.getTransactions(loanIdHolder[0]); + GetWorkingCapitalLoanTransactionIdResponse reversedAdjustment = transactions.stream() + .filter(txn -> adjustmentTransactionIdHolder[0].equals(txn.getId())).findFirst() + .orElseThrow(() -> new AssertionError("Charge adjustment transaction not found")); + assertEquals(Boolean.TRUE, reversedAdjustment.getReversed(), "Charge adjustment transaction should be flagged reversed"); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, feeCharge.getAmountPaid(), "Fee charge amount paid restored to 0"); + assertEqualBigDecimal(BigDecimal.valueOf(100), feeCharge.getAmountOutstanding(), "Fee charge outstanding restored to 100"); + + assertEquals("loanStatusType.closed.obligations.met", wcLoanHelper.getLoanDetails(loanIdHolder[0]).getStatus().getCode(), + "Loan remains closed — the adjustment only ever touched the fee bucket, never principal"); + }); + } + + @Test + void testChargeAdjustmentSurvivesReprocessingTriggeredByBackdatedRepayment() { + final Long[] loanIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + businessDateHelper.runAt("2026-01-01", () -> { + Long client = clientHelper.createClient("01 January 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 January 2026"); + feeLoanChargeIdHolder[0] = addCharge(loanIdHolder[0], false, 100, "01 January 2026"); + }); + businessDateHelper.runAt("2026-01-20", () -> { + wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); + }); + businessDateHelper.runAt("2026-01-25", () -> { + wcLoanHelper.makeRepayment(loanIdHolder[0], + WorkingCapitalLoanRequestBuilders.repayment(BigDecimal.valueOf(100), "10 January 2026")); + + GetBalance balance = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.valueOf(100), balance.getFeePaid(), "Fee is fully settled by the earlier-dated repayment"); + assertEqualBigDecimal(BigDecimal.valueOf(40), balance.getPrincipalPaid(), + "The charge adjustment's 40 must still be accounted for — redistributed onto principal since the fee is already settled"); + assertEqualBigDecimal(BigDecimal.valueOf(8960), balance.getPrincipalOutstanding(), "Principal outstanding is 9000 - 40"); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.valueOf(100), feeCharge.getAmountPaid(), "Fee charge fully paid once, not lost"); + assertEquals(Boolean.TRUE, feeCharge.getPaid(), "Fee charge should be flagged paid"); + }); + } + private Long addCharge(Long loanId, boolean penalty, double amount, String dueDate) { Long chargeId = wcLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(penalty, amount)); return wcLoanHelper.addCharge(loanId, WorkingCapitalLoanRequestBuilders.addCharge(chargeId, amount, dueDate)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java index 56a25591b95..e0bde686f44 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanPaymentAllocationRuleTest.java @@ -19,6 +19,7 @@ package org.apache.fineract.integrationtests.client.feign.tests; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import java.math.BigDecimal; @@ -29,19 +30,24 @@ import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.feign.util.FeignCalls; import org.apache.fineract.client.models.GetBalance; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.InlineJobRequest; import org.apache.fineract.client.models.PostPaymentAllocation; +import org.apache.fineract.client.models.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.client.models.WorkingCapitalLoanChargeData; 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.FineractFeignClientHelper; import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloanbreach.WorkingCapitalBreachHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; import org.junit.jupiter.api.AfterAll; @@ -170,7 +176,7 @@ void testChargeAdjustmentUsesItsOwnConfiguredAllocationOrderInsteadOfDefaultOrde }); businessDateHelper.runAt("2026-03-05", () -> { wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], - WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(60), "05 March 2026")); + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(60))); GetWorkingCapitalLoanTransactionIdResponse chargeAdjustment = findTransaction(wcLoanHelper.getTransactions(loanIdHolder[0]), "loanTransactionType.chargeAdjustment", LocalDate.of(2026, Month.MARCH, 5), BigDecimal.valueOf(60)); @@ -188,6 +194,134 @@ void testChargeAdjustmentUsesItsOwnConfiguredAllocationOrderInsteadOfDefaultOrde }); } + @Test + void testUndoOfChargeAdjustmentThatTouchedPrincipalRestoresPrincipalAndCharge() { + final Long[] loanIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + final Long[] adjustmentTransactionIdHolder = new Long[1]; + businessDateHelper.runAt("2026-04-01", () -> { + Long client = clientHelper.createClient("01 April 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(9000), "01 April 2026", + createProductWithChargeAdjustmentOverride()); + feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 100, "01 April 2026"); + }); + businessDateHelper.runAt("2026-04-05", () -> { + adjustmentTransactionIdHolder[0] = wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(60))); + }); + businessDateHelper.runAt("2026-04-10", () -> { + wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction()); + + GetWorkingCapitalLoanTransactionIdResponse reversedAdjustment = wcLoanHelper.getTransactions(loanIdHolder[0]).stream() + .filter(txn -> adjustmentTransactionIdHolder[0].equals(txn.getId())).findFirst() + .orElseThrow(() -> new AssertionError("Charge adjustment transaction not found")); + assertEquals(Boolean.TRUE, reversedAdjustment.getReversed()); + + GetBalance balance = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getPrincipalPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(9000), balance.getPrincipalOutstanding()); + assertEqualBigDecimal(BigDecimal.ZERO, balance.getFeePaid()); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, feeCharge.getAmountPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(100), feeCharge.getAmountOutstanding()); + }); + } + + @Test + void testUndoOfChargeAdjustmentThatClosedTheLoanReactivatesIt() { + final Long[] loanIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + final Long[] adjustmentTransactionIdHolder = new Long[1]; + businessDateHelper.runAt("2026-05-01", () -> { + Long client = clientHelper.createClient("01 May 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(40), "01 May 2026", + createProductWithChargeAdjustmentOverride()); + feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 40, "01 May 2026"); + + GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.getLoanDetails(loanIdHolder[0]); + assertNotNull(loanDetails.getStatus()); + assertEquals("loanStatusType.active", loanDetails.getStatus().getCode(), "Loan should start out active"); + }); + businessDateHelper.runAt("2026-05-05", () -> { + adjustmentTransactionIdHolder[0] = wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); + + GetBalance afterAdjustment = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, afterAdjustment.getPrincipalOutstanding()); + assertEqualBigDecimal(BigDecimal.valueOf(40), afterAdjustment.getPrincipalPaid()); + assertEqualBigDecimal(BigDecimal.ZERO, afterAdjustment.getFeePaid()); + + GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.getLoanDetails(loanIdHolder[0]); + assertNotNull(loanDetails.getStatus()); + assertEquals("loanStatusType.closed.obligations.met", loanDetails.getStatus().getCode(), + "Loan should be closed once the charge adjustment pays off the remaining principal"); + }); + businessDateHelper.runAt("2026-05-10", () -> { + wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction()); + + GetWorkingCapitalLoanTransactionIdResponse reversedAdjustment = wcLoanHelper.getTransactions(loanIdHolder[0]).stream() + .filter(txn -> adjustmentTransactionIdHolder[0].equals(txn.getId())).findFirst() + .orElseThrow(() -> new AssertionError("Charge adjustment transaction not found")); + assertEquals(Boolean.TRUE, reversedAdjustment.getReversed()); + + GetBalance afterUndo = balanceOf(loanIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, afterUndo.getPrincipalPaid()); + assertEqualBigDecimal(BigDecimal.valueOf(40), afterUndo.getPrincipalOutstanding()); + + assertEquals("loanStatusType.active", wcLoanHelper.getLoanDetails(loanIdHolder[0]).getStatus().getCode(), + "Loan must reactivate once the undo brings principal outstanding back above zero"); + + WorkingCapitalLoanChargeData feeCharge = findCharge(wcLoanHelper.getCharges(loanIdHolder[0]), feeLoanChargeIdHolder[0]); + assertEqualBigDecimal(BigDecimal.ZERO, feeCharge.getAmountPaid()); + }); + } + + @Test + void testUndoOfChargeAdjustmentTriggersABreach() { + final Long[] loanIdHolder = new Long[1]; + final Long[] feeLoanChargeIdHolder = new Long[1]; + final Long[] adjustmentTransactionIdHolder = new Long[1]; + final WorkingCapitalBreachHelper breachHelper = new WorkingCapitalBreachHelper(); + final Long breachId = breachHelper.create(breachHelper.createBreachRequest( + "WCL PayAlloc Breach " + Utils.uniqueRandomStringGenerator("", 8), 30, "DAYS", "PERCENTAGE", BigDecimal.TEN)); + + businessDateHelper.runAt("2026-06-01", () -> { + Long client = clientHelper.createClient("01 June 2026"); + loanIdHolder[0] = createAndDisburseLoanOnDate(client, BigDecimal.valueOf(5000), "01 June 2026", + createProductWithChargeAdjustmentOverrideAndBreach(breachId)); + feeLoanChargeIdHolder[0] = addFeeCharge(loanIdHolder[0], 500, "01 June 2026"); + }); + businessDateHelper.runAt("2026-06-06", () -> { + FeignCalls.ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanIdHolder[0]))); + + adjustmentTransactionIdHolder[0] = wcLoanHelper.adjustCharge(loanIdHolder[0], feeLoanChargeIdHolder[0], + WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(500))); + + List breachSchedule = wcLoanHelper.getBreachSchedule(loanIdHolder[0]); + assertFalse(breachSchedule.isEmpty(), "Breach schedule should be populated"); + WorkingCapitalLoanBreachScheduleData periodAfterAdjustment = breachSchedule.getFirst(); + assertEqualBigDecimal(BigDecimal.ZERO, periodAfterAdjustment.getOutstandingAmount()); + assertEquals(Boolean.FALSE, periodAfterAdjustment.getBreach(), "Breach period is fully covered, so not breached"); + }); + businessDateHelper.runAt("2026-08-30", () -> { + FeignCalls.ok(() -> FineractFeignClientHelper.getFineractFeignClient().inlineJob().executeInlineJob("WC_LOAN_COB", + new InlineJobRequest().addLoanIdsItem(loanIdHolder[0]))); + + wcLoanHelper.undoLoanTransaction(loanIdHolder[0], adjustmentTransactionIdHolder[0], + WorkingCapitalLoanRequestBuilders.undoTransaction()); + + List breachSchedule = wcLoanHelper.getBreachSchedule(loanIdHolder[0]); + assertFalse(breachSchedule.isEmpty(), "Breach schedule should be populated"); + WorkingCapitalLoanBreachScheduleData periodAfterUndo = breachSchedule.getFirst(); + assertEqualBigDecimal(BigDecimal.valueOf(500), periodAfterUndo.getOutstandingAmount()); + assertEquals(Boolean.TRUE, periodAfterUndo.getBreach(), "Undo brought back the shortfall on an already-elapsed period"); + }); + } + private Long addFeeCharge(Long loanId, double amount, String dueDate) { Long chargeId = wcLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, amount)); return wcLoanHelper.addCharge(loanId, WorkingCapitalLoanRequestBuilders.addCharge(chargeId, amount, dueDate)); @@ -245,6 +379,18 @@ private Long createProductWithChargeAdjustmentOverride() { return productId; } + private Long createProductWithChargeAdjustmentOverrideAndBreach(final Long breachId) { + String uniqueName = "WCL PayAlloc " + Utils.uniqueRandomStringGenerator("", 8); + String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder().withName(uniqueName) + .withShortName(uniqueShortName).withPaymentAllocationTypes(FEE_BEFORE_PRINCIPAL_ORDER) + .withPaymentAllocationForTransactionType(PostPaymentAllocation.TransactionTypeEnum.CHARGE_ADJUSTMENT, + PRINCIPAL_BEFORE_FEE_ORDER) + .withBreachId(breachId).build()).getResourceId(); + createdProductIds.add(productId); + return productId; + } + private static GetWorkingCapitalLoanTransactionIdResponse findTransaction(List transactions, String transactionCode, LocalDate transactionDate, BigDecimal amount) { return transactions.stream().filter(txn -> transactionDate.equals(txn.getTransactionDate())) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanReprocessingCorrectnessTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanReprocessingCorrectnessTest.java index f67ab71f8cf..98c77456455 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanReprocessingCorrectnessTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalLoanReprocessingCorrectnessTest.java @@ -84,8 +84,7 @@ void backdatedRepayment_preservesChargeAdjustment() { // A 40 charge adjustment partially settles the 100 fee: amountPaid 40, outstanding 60. businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-05"); - wcLoanHelper.adjustCharge(loanId, feeLoanChargeId, - WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40), "05 January 2026")); + wcLoanHelper.adjustCharge(loanId, feeLoanChargeId, WorkingCapitalLoanRequestBuilders.chargeAdjustment(BigDecimal.valueOf(40))); // R1 on day 10: settles the remaining 60 of the fee, then the leftover would be nothing (60 == remaining). businessDateHelper.updateBusinessDate("BUSINESS_DATE", "2026-01-20"); From d0f953a3a353a202cebc144ffccdb197513703bf Mon Sep 17 00:00:00 2001 From: Peter Kovacs Date: Tue, 14 Jul 2026 11:13:53 +0200 Subject: [PATCH 3/3] FINERACT-2455: Add charge adjustment unified undo functionality - E2E tests --- .../loan/WorkingCapitalChargeStepDef.java | 15 -- ...WorkingCapitalLoanChargeAdjustment.feature | 159 +++++++++++++++++- 2 files changed, 151 insertions(+), 23 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalChargeStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalChargeStepDef.java index 62a0983b0f5..9ce0af4fcdf 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalChargeStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalChargeStepDef.java @@ -455,21 +455,6 @@ public void makeWcPenaltyChargeAdjustment(final Double amount) { log.debug("WC penalty charge adjustment response: {}", response); } - @When("Admin makes a charge adjustment for the last added charge with {double} amount and transaction date {string} on working capital loan") - public void makeWcChargeAdjustmentWithDate(final Double amount, final String transactionDate) { - final Long loanId = getLoanId(); - final Long loanChargeId = getLastAddedLoanChargeId(); - final LocalDate parsedDate = LocalDate.parse(transactionDate, FORMATTER); - final PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request = new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest() - .amount(BigDecimal.valueOf(amount)).transactionDate(parsedDate.format(FORMATTER_API)).dateFormat(DATE_FORMAT_API) - .locale("en"); - final PostWorkingCapitalLoansLoanIdChargesChargeIdResponse response = ok( - () -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "adjustment")); - Assertions.assertNotNull(response); - testContext().set(TestContextKey.WORKING_CAPITAL_CHARGE_ADJUSTMENT_RESPONSE, response); - log.debug("WC charge adjustment with date response: {}", response); - } - @Then("Making a charge adjustment with {double} amount on working capital loan results an error with the following data:") public void makeWcChargeAdjustmentFails(final Double amount, final DataTable table) { final Long loanId = getLoanId(); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature index 28673b9abea..446a9b09e19 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustment.feature @@ -304,9 +304,8 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature | httpCode | errorMessage | | 400 | Failed data validation due to: transaction.already.undone | - #TODO should this be removed? - @Skip @TestRailId:C85233 - Scenario: Verify Working Capital charge adjustment - UC13: charge adjustment with explicit transaction date + @TestRailId:C85545 + Scenario: Verify Working Capital charge adjustment undo - UC1: undo of charge adjustment on active loan restores fee balances Given Admin sets the business date to "01 January 2026" And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | @@ -314,15 +313,159 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature 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 100.0 transaction amount - When Admin sets the business date to "15 January 2026" - And Admin makes a charge adjustment for the last added charge with 100.0 amount and transaction date "10 January 2026" on working capital loan + When Admin makes a charge adjustment for the last added charge with 100.0 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 | | 10 January 2026 | Charge Adjustment | 100.0 | 0.0 | 100.0 | 0.0 | false | - And Working Capital Loan has charges with the following data: - | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | - | Working Capital Loan Fee | 10 January 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin reverts the last charge adjustment 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 | + | 10 January 2026 | Charge Adjustment | 100.0 | 0.0 | 100.0 | 0.0 | true | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 9000.0 | + Then Working Capital loan status will be "ACTIVE" + # --- Close loan --- + When Admin closes the Working Capital loan with a full repayment on "15 January 2026" + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85546 + Scenario: Verify Working Capital charge adjustment undo - UC2: undo of charge adjustment on closed loan - loan remains closed + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "31 January 2026" due date and 100.0 transaction amount + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "02 January 2026" with 9000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin makes a charge adjustment for the last added charge with 40.0 amount on working capital loan + Then Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 60.0 | 40.0 | 0.0 | 0.0 | 0.0 | + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "03 January 2026" + And Admin reverts the last charge adjustment 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 | + | 02 January 2026 | Repayment | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Charge Adjustment | 40.0 | 0.0 | 40.0 | 0.0 | true | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85547 + Scenario: Verify Working Capital charge adjustment undo - UC3: undo of charge adjustment on active loan recalculates delinquency schedule + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "31 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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 | 0.0 | 270.0 | false | 270.0 | 1 | + | 2 | 2026-01-31 | 2026-03-01 | 270.0 | 0.0 | 270.0 | null | null | null | + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "31 January 2026" due date and 270.0 transaction amount + When Admin sets the business date to "01 February 2026" + And Admin makes a charge adjustment for the last added charge with 270.0 amount on working capital loan + Then Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 270.0 | 0.0 | 270.0 | 0.0 | 0.0 | 0.0 | + And 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 | 0.0 | 270.0 | null | null | null | + When Admin reverts the last charge adjustment 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 | + | 01 February 2026 | Charge Adjustment | 270.0 | 0.0 | 270.0 | 0.0 | true | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 270.0 | 270.0 | 0.0 | 0.0 | 0.0 | 0.0 | + And 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 | 0.0 | 270.0 | false | 270.0 | 2 | + | 2 | 2026-01-31 | 2026-03-01 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Working Capital loan status will be "ACTIVE" + # --- Close loan --- + When Admin closes the Working Capital loan with a full repayment on "01 February 2026" + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85548 + Scenario: Verify Working Capital charge adjustment undo - UC4: undo of repayment on closed loan reopens loan to ACTIVE + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 9000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "15 January 2026" + And Customer undo "1"th "REPAYMENT" transaction made on "10 January 2026" 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 | + | 10 January 2026 | Repayment | 9000.0 | 9000.0 | 0.0 | 0.0 | true | + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 9000.0 | + Then Working Capital loan status will be "ACTIVE" + # --- Close loan --- + When Admin closes the Working Capital loan with a full repayment on "15 January 2026" + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C85549 + Scenario: Verify Working Capital charge adjustment undo - UC5: undo of charge adjustment with principal portion (via reprocessing) reopens closed loan to ACTIVE + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_DUE_FEE_PENALTY_PRINCIPAL | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 500.0 transaction amount + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin makes a charge adjustment for the last added charge with 500.0 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 | + | 10 January 2026 | Charge Adjustment | 500.0 | 0.0 | 500.0 | 0.0 | false | + When Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 500.0 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 | + | 10 January 2026 | Charge Adjustment | 500.0 | 0.0 | 500.0 | 0.0 | false | + | 10 January 2026 | Repayment | 500.0 | 500.0 | 0.0 | 0.0 | false | + And Customer makes repayment on "20 January 2026" with 8500.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "25 January 2026" + And Admin reverts the last charge adjustment 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 | + | 10 January 2026 | Charge Adjustment | 500.0 | 0.0 | 500.0 | 0.0 | true | + | 10 January 2026 | Repayment | 500.0 | 0.0 | 500.0 | 0.0 | false | + | 20 January 2026 | Repayment | 8500.0 | 8500.0 | 0.0 | 0.0 | false | + And Working Capital loan balance payload contains the following fields: + | field | value | + | principalOutstanding | 500.0 | + Then Working Capital loan status will be "ACTIVE" +# --- Close loan --- + When Admin closes the Working Capital loan with a full repayment on "25 January 2026" + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET"