diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index fed93fe0e27..ac1f5b2531d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -1192,6 +1192,15 @@ public CommandWrapperBuilder adjustmentForWorkingCapitalLoanCharge(final Long lo return this; } + public CommandWrapperBuilder waiveWorkingCapitalLoanCharge(final Long loanId, final Long loanChargeId) { + this.actionName = ACTION_WAIVE; + this.entityName = ENTITY_WORKINGCAPITALLOANCHARGE; + this.entityId = loanChargeId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/charges/" + loanChargeId; + return this; + } + public CommandWrapperBuilder updateLoanCharge(final Long loanId, final Long loanChargeId) { this.actionName = ACTION_UPDATE; this.entityName = ENTITY_LOANCHARGE; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java index 399693d7593..6c2707a22a1 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/TransactionType.java @@ -44,6 +44,7 @@ public enum TransactionType { INTEREST_REFUND("interestRefund"), // WRITE_OFF("writeOff"), // RECOVERY_REPAYMENT("recoveryRepayment"), // + WAIVE_CHARGES("waiveCharges"), // DISCOUNT_FEE("discountFee"), // DISCOUNT_FEE_ADJUSTMENT("discountFeeAdjustment"), // DISCOUNT_FEE_AMORTIZATION("discountFeeAmortization"), // diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java index ce563b0e2d3..56dbe1e6a3f 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java @@ -155,6 +155,7 @@ import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanAdjustTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanChargeOffTransactionBusinessEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanChargeWaiverTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDisbursalTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent; @@ -1266,6 +1267,14 @@ public void workingCapitalLoanChargeAdjustmentTransactionEventCheck(final Long l expectedAmount, false); } + public void workingCapitalLoanChargeWaiverTransactionEventCheck(final Long loanId, final BigDecimal expectedAmount) { + waitForTransactionCommit(); + final GetWorkingCapitalLoanTransactionIdResponse transaction = findLastWorkingCapitalLoanTransaction(loanId, "waiveCharges", false, + "Charge waiver transaction not found"); + workingCapitalLoanTransactionEventCheck(WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.class, loanId, transaction, + expectedAmount, false); + } + public void workingCapitalLoanChargeOffTransactionEventCheck(final Long loanId, final BigDecimal expectedAmount) { waitForTransactionCommit(); final GetWorkingCapitalLoanTransactionIdResponse transaction = findLastWorkingCapitalLoanTransaction(loanId, "chargeOff", false, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/transaction/WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/transaction/WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.java new file mode 100644 index 00000000000..ea08e365ebc --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/transaction/WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.java @@ -0,0 +1,27 @@ +/** + * 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.test.messaging.event.workingcapitalloan.transaction; + +public class WorkingCapitalLoanChargeWaiverTransactionBusinessEvent extends AbstractWorkingCapitalLoanTransactionEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanChargeWaiverTransactionBusinessEvent"; + } +} 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 305b9436f18..0d946bb1393 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 @@ -326,6 +326,13 @@ private List fetchValuesOfCharge(final List header, final Workin case "Due Date" -> actualValues.add(charge.getDueDate() == null ? null : FORMATTER.format(charge.getDueDate())); case "Amount" -> actualValues .add(charge.getAmount() == null ? null : new Utils.DoubleFormatter(charge.getAmount().doubleValue()).format()); + case "Amount Paid" -> actualValues.add( + charge.getAmountPaid() == null ? null : new Utils.DoubleFormatter(charge.getAmountPaid().doubleValue()).format()); + case "Amount Waived" -> actualValues.add(charge.getAmountWaived() == null ? null + : new Utils.DoubleFormatter(charge.getAmountWaived().doubleValue()).format()); + case "Amount Outstanding" -> actualValues.add(charge.getAmountOutstanding() == null ? null + : new Utils.DoubleFormatter(charge.getAmountOutstanding().doubleValue()).format()); + case "Paid" -> actualValues.add(charge.getPaid() == null ? null : String.valueOf(charge.getPaid())); case "Currency" -> actualValues.add(charge.getCurrency() == null ? null : charge.getCurrency().getCode()); case "isPenalty" -> actualValues.add(charge.getPenalty() == null ? null : String.valueOf(charge.getPenalty())); case "Charge Time Type" -> @@ -524,6 +531,66 @@ public void makeWcChargeAdjustmentFails(final Double amount, final DataTable tab log.info("Verified WC charge adjustment failed with status {} and message: {}", exception.getStatus(), expectedErrorMessage); } + @When("Admin waives the last added charge on working capital loan") + public void waiveLastAddedWcCharge() { + final Long loanId = getLoanId(); + final Long loanChargeId = getLastAddedLoanChargeId(); + final PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request = new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest() + .locale("en"); + final PostWorkingCapitalLoansLoanIdChargesChargeIdResponse response = ok( + () -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "waive")); + Assertions.assertNotNull(response); + testContext().set(TestContextKey.WORKING_CAPITAL_CHARGE_WAIVER_RESPONSE, response); + log.debug("WC charge waiver response: {}", response); + } + + @Then("Waiving the last added charge on working capital loan results an error with the following data:") + public void waiveLastAddedWcChargeFails(final DataTable table) { + final Long loanId = getLoanId(); + final Long loanChargeId = getLastAddedLoanChargeId(); + final PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request = new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest() + .locale("en"); + final Map expectedData = table.asMaps().get(0); + final int expectedHttpCode = Integer.parseInt(expectedData.get("httpCode")); + final String expectedErrorMessage = expectedData.get("errorMessage").trim(); + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "waive")); + assertHttpStatus(exception, expectedHttpCode); + assertErrorMessage(exception, expectedErrorMessage); + log.info("Verified WC charge waiver failed with status {} and message: {}", exception.getStatus(), expectedErrorMessage); + } + + /** + * A separate step because every other waive step sends a body without an amount; this one sends one, to prove a + * partial waiver cannot be asked for. + */ + @Then("Waiving the last added charge on working capital loan with an amount results an error with the following data:") + public void waiveLastAddedWcChargeWithAmountFails(final DataTable table) { + final Long loanId = getLoanId(); + final Long loanChargeId = getLastAddedLoanChargeId(); + final PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request = new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest() + .amount(BigDecimal.ONE).locale("en"); + final Map expectedData = table.asMaps().get(0); + final int expectedHttpCode = Integer.parseInt(expectedData.get("httpCode")); + final String expectedErrorMessage = expectedData.get("errorMessage").trim(); + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "waive")); + assertHttpStatus(exception, expectedHttpCode); + assertErrorMessage(exception, expectedErrorMessage); + log.info("Verified WC charge waiver with an amount failed with status {} and message: {}", exception.getStatus(), + expectedErrorMessage); + } + + @When("Admin reverts the last charge waiver on working capital loan") + public void revertLastWcChargeWaiver() { + final Long loanId = getLoanId(); + final GetWorkingCapitalLoanTransactionIdResponse waiverTxn = getLastChargeWaiverTransaction(loanId, false); + final ExecuteWorkingCapitalLoanTransactionCommandRequest request = new ExecuteWorkingCapitalLoanTransactionCommandRequest(); + ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId(loanId, + waiverTxn.getId(), "undo", request)); + log.debug("Reverted WC charge waiver transaction id={} on loan {}", waiverTxn.getId(), loanId); + } + @When("Admin reverts the last charge adjustment on working capital loan") public void revertLastWcChargeAdjustment() { final Long loanId = getLoanId(); @@ -597,6 +664,17 @@ private Long getLastAddedChargeIdByPenaltyFlag(final Long loanId, final boolean .orElseThrow(() -> new IllegalStateException("No active " + chargeType + " charge found on loan " + loanId)); } + private GetWorkingCapitalLoanTransactionIdResponse getLastChargeWaiverTransaction(final Long loanId, final Boolean excludeReversed) { + final GetWorkingCapitalLoanTransactionsResponse body = ok( + () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); + Assertions.assertNotNull(body.getContent(), "No WC loan transactions found"); + return body.getContent().stream() + .filter(t -> t.getType() != null && "loanTransactionType.waiveCharges".equals(t.getType().getCode())) + .filter(t -> excludeReversed == null || !Boolean.TRUE.equals(t.getReversed())) + .max(Comparator.comparing(GetWorkingCapitalLoanTransactionIdResponse::getId)) + .orElseThrow(() -> new IllegalStateException("No charge waiver transaction found on loan " + loanId)); + } + private GetWorkingCapitalLoanTransactionIdResponse getLastChargeAdjustmentTransaction(final Long loanId, final Boolean excludeReversed) { final GetWorkingCapitalLoanTransactionsResponse body = ok( diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index d28dc684f39..567282a035b 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -1841,6 +1841,11 @@ public void aWorkingCapitalLoanChargeAdjustmentTransactionBusinessEventIsRaised( eventCheckHelper.workingCapitalLoanChargeAdjustmentTransactionEventCheck(getCreatedLoanId(), new BigDecimal(amount)); } + @Then("a Working Capital Loan Charge Waiver transaction business event is raised with {string} EUR amount") + public void aWorkingCapitalLoanChargeWaiverTransactionBusinessEventIsRaised(final String amount) { + eventCheckHelper.workingCapitalLoanChargeWaiverTransactionEventCheck(getCreatedLoanId(), new BigDecimal(amount)); + } + @Then("a Working Capital Loan Charge Off transaction business event is raised with {string} EUR amount") public void aWorkingCapitalLoanChargeOffTransactionBusinessEventIsRaised(final String amount) { eventCheckHelper.workingCapitalLoanChargeOffTransactionEventCheck(getCreatedLoanId(), new BigDecimal(amount)); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index a97250de23a..fdd4667461d 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -380,6 +380,7 @@ public abstract class TestContextKey { public static final String WORKING_CAPITAL_LOAN_CHARGE_IDS = "workingCapitalLoanChargeIds"; public static final String WORKING_CAPITAL_CHARGE_TEMPLATE = "workingCapitalChargeTemplate"; public static final String WORKING_CAPITAL_CHARGE_ADJUSTMENT_RESPONSE = "workingCapitalChargeAdjustmentResponse"; + public static final String WORKING_CAPITAL_CHARGE_WAIVER_RESPONSE = "workingCapitalChargeWaiverResponse"; public static final String WORKING_CAPITAL_LOAN_DISBURSE_DISCOUNT_EXTERNAL_ID_USER_GENERATED = "workingCapitalLoanDisburseDiscountExternalIdUserGenerated"; public static final String WORKING_CAPITAL_LOAN_DISCOUNT_FEE_EXTERNAL_ID_USER_GENERATED = "workingCapitalLoanDiscountFeeExternalIdUserGenerated"; public static final String WORKING_CAPITAL_LOAN_DISCOUNT_FEE_RESPONSE = "workingCapitalLoanDiscountFeeResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeWaiver.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeWaiver.feature new file mode 100644 index 00000000000..f9a5f7ec332 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeWaiver.feature @@ -0,0 +1,281 @@ +@SerialChargeAccrualConfig +@WorkingCapital +@WorkingCapitalLoanChargeWaiverFeature +Feature: Working Capital Loan Charge Waiver + + Scenario: Waive an unpaid fee charge with a past due 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: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# A charge cannot be created with a due date in the past, so the only way into the past-due-date branch is to +# create it with a future due date and let the business date overtake it. + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "15 January 2026" due date and 100.0 transaction amount + When Admin sets the business date to "20 January 2026" + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | false | + 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 | 0.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Waive an unpaid fee charge with a future due 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: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "20 January 2026" due date and 100.0 transaction amount + When Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | false | + 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 | 0.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Waive a partially paid charge waives only the remainder + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And 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 | + When Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "60.0" EUR amount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Charge Adjustment | 40.0 | 0.0 | 40.0 | 0.0 | false | + | 10 January 2026 | Waive loan charges | 60.0 | 0.0 | 0.0 | 0.0 | false | + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Amount Paid | Amount Waived | Amount Outstanding | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | 40.0 | 60.0 | 0.0 | + 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 | 40.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Waiving a fully paid charge is rejected + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin makes a charge adjustment for the last added charge with 100.0 amount on working capital loan + Then Waiving the last added charge on working capital loan results an error with the following data: + | httpCode | errorMessage | + | 403 | has no outstanding amount to waive | + + Scenario: Undo a charge waiver restores the charge to outstanding + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Amount Paid | Amount Waived | Amount Outstanding | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | 0.0 | 100.0 | 0.0 | + When Admin reverts the last charge waiver on working capital loan + Then a Working Capital Loan Adjust Transaction business event is raised for the reversed "waiveCharges" transaction + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Amount Paid | Amount Waived | Amount Outstanding | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | 0.0 | 0.0 | 100.0 | + 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 | +# Moved back to outstanding state means back in the allocation pipeline, not only a changed number. + When Customer makes repayment on "10 January 2026" with 100.0 transaction 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 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Re-waiving a charge after its waiver was undone succeeds + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + When Admin reverts the last charge waiver on working capital loan + Then a Working Capital Loan Adjust Transaction business event is raised for the reversed "waiveCharges" transaction + When Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | true | + | 10 January 2026 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | false | + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Amount Paid | Amount Waived | Amount Outstanding | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | 0.0 | 100.0 | 0.0 | + + Scenario: Waiver closes the loan and accrues the income of the part that was paid + When 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 1000 | 100000 | 18 | 0 | + And Global config "charge-accrual-date" value set to "due-date" + And Customer makes repayment on "01 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + When Admin sets the business date to "01 June 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 "01 June 2026" due date and 100.0 transaction amount + Then Working Capital loan status will be "ACTIVE" +# Settling only part of the charge keeps the loan open, so the closure - and with it the accrual of the part that +# was paid - can only come from the waiver: the accrual COB step for this date ran before the charge existed. + When Admin makes a charge adjustment for the last added charge with 40.0 amount on working capital loan + Then Working Capital loan status will be "ACTIVE" + When Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "60.0" EUR amount + And Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + 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 | 40.0 | 0.0 | 0.0 | 0.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Repayment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 01 June 2026 | Charge Adjustment | 40.0 | 0.0 | 40.0 | 0.0 | false | + | 01 June 2026 | Waive loan charges | 60.0 | 0.0 | 0.0 | 0.0 | false | + | 01 June 2026 | Accrual | 40.0 | 0.0 | 40.0 | 0.0 | false | + And Working Capital Loan Transactions tab has a "ACCRUAL" transaction with date "01 June 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 40.0 | | + | INCOME | 404007 | Fee Income | | 40.0 | + + Scenario: Waiver on a written-off loan is rejected + When 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 writes off the Working Capital loan on "15 January 2026" + Then Working Capital loan status will be "CLOSED_WRITTEN_OFF" + And Waiving the last added charge on working capital loan results an error with the following data: + | httpCode | errorMessage | + | 403 | Charge waiver is not supported for the status of | + + Scenario: A repayment backdated before a charge waiver leaves the waived amount untouched + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "15 January 2026" due date and 100.0 transaction amount + When Admin sets the business date to "20 January 2026" + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount +# The backdated payment replays the whole history, and the replay resets only the paid distribution - the waived +# bucket has to come through it untouched. + When Customer makes repayment on "12 January 2026" with 50.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 | + | 12 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | false | + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Amount Paid | Amount Waived | Amount Outstanding | + | Working Capital Loan Fee | 15 January 2026 | 100.0 | 0.0 | 100.0 | 0.0 | + 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 | 0.0 | 0.0 | 0.0 | 0.0 | + + Scenario: A waived charge is reported with its waived amount and zero outstanding + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount +# The paid flag stays false on a waived charge - only a payment sets it - and the waived amount is what explains +# the zero outstanding. + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Amount Paid | Amount Waived | Amount Outstanding | Paid | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | 0.0 | 100.0 | 0.0 | false | + + Scenario: A waived charge is not accrued afterwards + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "15 January 2026" due date and 100.0 transaction amount + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + When Admin sets the business date to "16 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | false | + + Scenario: Adjusting a fully waived charge is rejected + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount +# Without the waived part taken out of the amount available for adjustment, this call would pass and then settle +# principal instead of the charge it names, because the allocator caps every charge at its outstanding. + And Making a charge adjustment with 100.0 amount on working capital loan results an error with the following data: + | httpCode | errorMessage | + | 403 | Transaction amount cannot be higher than the available charge amount for adjustment | + + Scenario: Passing an amount to the charge waiver request is rejected + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 +# A waiver always clears the whole remainder, so asking for a partial one must not even be expressible. + Then Waiving the last added charge on working capital loan with an amount results an error with the following data: + | httpCode | errorMessage | + | 400 | The parameter amount is not supported | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeWaiverAccounting.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeWaiverAccounting.feature new file mode 100644 index 00000000000..39523846047 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeWaiverAccounting.feature @@ -0,0 +1,311 @@ +@SerialChargeAccrualConfig +@WorkingCapital +@WorkingCapitalLoanChargeWaiverAccountingFeature +Feature: Working Capital Loan Charge Waiver Accounting + + Scenario: Waiving an accrued fee charge books Write-off Expense against Fees Receivable + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "15 January 2026" due date and 100.0 transaction amount + When Admin sets the business date to "16 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital Loan Transactions tab has a "ACCRUAL" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 100.0 | | + | INCOME | 404007 | Fee Income | | 100.0 | + When Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Accrual | 100.0 | 0.0 | 100.0 | 0.0 | false | + | 15 January 2026 | Waive loan charges | 100.0 | 0.0 | 100.0 | 0.0 | false | + And Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | e4 | Written off | 100.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | + 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 | 0.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Waiving an accrued penalty charge books against Penalties Receivable + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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_PENALTY" specified due date charge to working capital loan with "15 January 2026" due date and 50.0 transaction amount + When Admin sets the business date to "16 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "50.0" EUR amount + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Accrual | 50.0 | 0.0 | 0.0 | 50.0 | false | + | 15 January 2026 | Waive loan charges | 50.0 | 0.0 | 0.0 | 50.0 | false | + And Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | e4 | Written off | 50.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 50.0 | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 0.0 | 0.0 | 0.0 | 50.0 | 0.0 | 0.0 | + + Scenario: Waiving a charge that was never accrued books no journal entries + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "20 January 2026" due date and 100.0 transaction amount + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount +# There is no receivable behind income that was never recognized, so crediting one would drive it negative. + And Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + 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 | 0.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Undoing a charge waiver reverses its journal entries + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "15 January 2026" due date and 100.0 transaction amount + When Admin sets the business date to "16 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + When Admin reverts the last charge waiver on working capital loan + Then a Working Capital Loan Adjust Transaction business event is raised for the reversed "waiveCharges" transaction + And Working Capital Loan Transactions tab has a reversed "WAIVE_CHARGES" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | e4 | Written off | 100.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | + | EXPENSE | e4 | Written off | | 100.0 | + | ASSET | 112603 | Interest/Fee Receivable | 100.0 | | + 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 | + + Scenario: A waiver dated before the charge-off credits the receivable and takes the fee back out of the charge-off + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "15 January 2026" due date and 100.0 transaction amount + When Admin sets the business date to "16 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital Loan Transactions tab has a "ACCRUAL" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 100.0 | | + | INCOME | 404007 | Fee Income | | 100.0 | +# The waiver is dated on the charge due date, so it sorts before the charge-off even though it is booked after it. +# As of that date the borrower no longer owed the fee, so the charge-off must stop writing it off. + When Admin sets the business date to "18 January 2026" + And Admin charges off the Working Capital loan on "18 January 2026" + When Admin sets the business date to "20 January 2026" + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + And Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | e4 | Written off | 100.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Accrual | 100.0 | 0.0 | 100.0 | 0.0 | false | + | 15 January 2026 | Waive loan charges | 100.0 | 0.0 | 100.0 | 0.0 | false | + | 18 January 2026 | Charge-off | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + 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 | 0.0 | 0.0 | 0.0 | 0.0 | + + Scenario: Waiving the remainder of a partially paid charge before it is accrued accrues only the paid portion + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 +# The charge payment credits the receivable whether or not an accrual preceded it, leaving it at -40. The later +# accrual has to recognize exactly the paid part to net that back to zero. + And Admin makes a charge adjustment for the last added charge with 40.0 amount on working capital loan + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "60.0" EUR amount + When Admin sets the business date to "11 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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 | 40.0 | 0.0 | 40.0 | 0.0 | false | + | 10 January 2026 | Waive loan charges | 60.0 | 0.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Accrual | 40.0 | 0.0 | 40.0 | 0.0 | false | + And Working Capital Loan Transactions tab has a "ACCRUAL" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 40.0 | | + | INCOME | 404007 | Fee Income | | 40.0 | + + Scenario: Undoing a charge waiver tops the reduced accrual back up to the full charge amount + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin makes a charge adjustment for the last added charge with 40.0 amount on working capital loan + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "60.0" EUR amount + When Admin sets the business date to "11 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin reverts the last charge waiver on working capital loan + Then a Working Capital Loan Adjust Transaction business event is raised for the reversed "waiveCharges" transaction +# The restored part of the charge must still be able to reach the income statement. A boolean "already accrued" +# guard would silence every later accrual and leave it unrecognized forever. + When Admin sets the business date to "12 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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 | 40.0 | 0.0 | 40.0 | 0.0 | false | + | 10 January 2026 | Waive loan charges | 60.0 | 0.0 | 0.0 | 0.0 | true | + | 10 January 2026 | Accrual | 40.0 | 0.0 | 40.0 | 0.0 | false | + | 10 January 2026 | Accrual | 60.0 | 0.0 | 60.0 | 0.0 | false | + And Working Capital Loan Transactions tab has 2 "ACCRUAL" transactions with date "10 January 2026" which have the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 40.0 | | + | INCOME | 404007 | Fee Income | | 40.0 | + | ASSET | 112603 | Interest/Fee Receivable | 60.0 | | + | INCOME | 404007 | Fee Income | | 60.0 | + And 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 | + + Scenario: Re-waiving a charge before the accrual catches up books no journal entries + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 + And Admin makes a charge adjustment for the last added charge with 40.0 amount on working capital loan + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "60.0" EUR amount + When Admin sets the business date to "11 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin reverts the last charge waiver on working capital loan + Then a Working Capital Loan Adjust Transaction business event is raised for the reversed "waiveCharges" transaction +# Only 40 was ever accrued, and the payment already consumed all of it, so the 60 being waived again has no +# receivable behind it - a flat "accrued or not" rule would credit Fees Receivable against nothing. + When Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "60.0" EUR amount + And Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + 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 | 40.0 | 0.0 | 0.0 | 0.0 | + + Scenario: A waiver dated after the charge-off leaves the fee the charge-off wrote off in place + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "25 January 2026" due date and 100.0 transaction amount +# The charge is outstanding from the day it is added, so the charge-off writes it off before it has been accrued. + When Admin sets the business date to "18 January 2026" + And Admin charges off the Working Capital loan on "18 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "18 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 9000.0 | | + | INCOME | 404008 | Fee Charge Off | 100.0 | | + | ASSET | 112601 | Loans Receivable | | 9000.0 | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | +# The accrual lands after the charge-off and debits the receivable that charge-off had already credited, so the two +# net out and nothing is left stranded on the books. + When Admin sets the business date to "26 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital Loan Transactions tab has a "ACCRUAL" transaction with date "25 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 100.0 | | + | INCOME | 404007 | Fee Income | | 100.0 | + When Admin waives the last added charge on working capital loan + Then Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "25 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | e4 | Written off | 100.0 | | + | INCOME | 404008 | Fee Charge Off | | 100.0 | +# Replaying the whole history must not take that fee back out of the charge-off: on 18 January it was still owed. + When Customer makes repayment on "12 January 2026" with 1000.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 | + | 12 January 2026 | Repayment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 18 January 2026 | Charge-off | 8100.0 | 8000.0 | 100.0 | 0.0 | false | + | 25 January 2026 | Accrual | 100.0 | 0.0 | 100.0 | 0.0 | false | + | 25 January 2026 | Waive loan charges | 100.0 | 0.0 | 100.0 | 0.0 | false | + + Scenario: Undoing a waiver on a charged-off loan gives the fee back to the charge-off + 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_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Global config "charge-accrual-date" value set to "due-date" + And 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 "20 January 2026" due date and 100.0 transaction amount +# Waived before its due date, so nothing was accrued yet and the waiver books no journal entries. + And Admin waives the last added charge on working capital loan + Then a Working Capital Loan Charge Waiver transaction business event is raised with "100.0" EUR amount + When Admin sets the business date to "15 January 2026" + And Admin charges off the Working Capital loan on "15 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 9000.0 | | + | ASSET | 112601 | Loans Receivable | | 9000.0 | +# Undoing the waiver puts the fee back into the outstanding, so the charge-off has to cover it after all. + When Admin sets the business date to "16 January 2026" + And Admin reverts the last charge waiver on working capital loan + Then a Working Capital Loan Adjust Transaction business event is raised for the reversed "waiveCharges" transaction + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 10 January 2026 | Waive loan charges | 100.0 | 0.0 | 0.0 | 0.0 | true | + | 15 January 2026 | Charge-off | 9100.0 | 9000.0 | 100.0 | 0.0 | false | +# The receivable the restated charge-off credited is matched by the accrual the undo made due again. + When Admin sets the business date to "21 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital Loan Transactions tab has a "ACCRUAL" transaction with date "20 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112603 | Interest/Fee Receivable | 100.0 | | + | INCOME | 404007 | Fee Income | | 100.0 | +# Waiving again on the charged-off loan credits the charge-off income the charge-off itself debited. + When Admin waives the last added charge on working capital loan + Then Working Capital Loan Transactions tab has a "WAIVE_CHARGES" transaction with date "20 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | e4 | Written off | 100.0 | | + | INCOME | 404008 | Fee Charge Off | | 100.0 | 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 78d1d02d586..90f4088bbcd 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 @@ -131,6 +131,7 @@ private List plannedPostings(final WorkingCapitalLoan loan, final case LoanTransactionType.CHARGE_OFF -> chargeOffPostings(loan, principalPortion, feesPortion, penaltiesPortion); case LoanTransactionType.WRITEOFF -> writeOffPostings(loan, principalPortion, feesPortion, penaltiesPortion, isChargedOff); case LoanTransactionType.RECOVERY_REPAYMENT -> recoveryPaymentPostings(txn); + case LoanTransactionType.WAIVE_CHARGES -> chargeWaiverPostings(feesPortion, penaltiesPortion, isChargedOff); default -> throw new NotImplementedException( "Post Journal Entries is not implemented yet for " + txn.getTypeOf().getCode() + " for Working Capital Loan"); }; @@ -242,6 +243,24 @@ private List writeOffPostings(final WorkingCapitalLoan loan, fina LedgerPosting.credit(CashAccountsForLoan.PENALTIES_RECEIVABLE, penaltiesPortion)); } + /** + * Only the recognized part of the waiver reaches the allocation, so a charge whose income was never accrued books + * nothing - crediting a receivable that does not exist would drive it negative. A waiver addresses exactly one + * charge, so one credit leg is always zero, and it carries no write-off reason to map the debit onto. On a + * charged-off loan the receivables are already off the books, so the credits go to the charged-off income accounts, + * as in {@link #writeOffPostings}. + */ + private List chargeWaiverPostings(final BigDecimal feesPortion, final BigDecimal penaltiesPortion, + final boolean isChargedOff) { + final CashAccountsForLoan feesAccount = isChargedOff ? CashAccountsForLoan.INCOME_FROM_CHARGE_OFF_FEES + : CashAccountsForLoan.FEES_RECEIVABLE; + final CashAccountsForLoan penaltiesAccount = isChargedOff ? CashAccountsForLoan.INCOME_FROM_CHARGE_OFF_PENALTY + : CashAccountsForLoan.PENALTIES_RECEIVABLE; + + return List.of(LedgerPosting.debit(CashAccountsForLoan.LOSSES_WRITTEN_OFF, MathUtil.add(feesPortion, penaltiesPortion)), + LedgerPosting.credit(feesAccount, feesPortion), LedgerPosting.credit(penaltiesAccount, penaltiesPortion)); + } + /** * The debit leg a write-off expenses the loss to: the expense account the product maps to the loan's write-off * reason, falling back to the generic Losses Written-off account when the write-off carries no reason or the diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index a4046857a42..1ad61f30cf0 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -144,7 +144,7 @@ public void givenAllConfigurationWhenValidatedThenValidationSuccessful() throws "WorkingCapitalLoanDelinquencyPauseBusinessEvent", "WorkingCapitalLoanDelinquencyResumeBusinessEvent", "WorkingCapitalLoanDelinquencyRescheduleBusinessEvent", "WorkingCapitalLoanDelinquencyResetBusinessEvent", "WorkingCapitalLoanDelinquencyUndoResetBusinessEvent", "WorkingCapitalLoanAccrualTransactionBusinessEvent", - "WorkingCapitalLoanAccrualAdjustmentTransactionBusinessEvent"); + "WorkingCapitalLoanAccrualAdjustmentTransactionBusinessEvent", "WorkingCapitalLoanChargeWaiverTransactionBusinessEvent"); List tenants = List .of(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.java new file mode 100644 index 00000000000..6529b50abf8 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/transaction/WorkingCapitalLoanChargeWaiverTransactionBusinessEvent.java @@ -0,0 +1,39 @@ +/** + * 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.infrastructure.event.business.domain.workingcapitalloan.transaction; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; + +public class WorkingCapitalLoanChargeWaiverTransactionBusinessEvent extends WorkingCapitalLoanTransactionBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanChargeWaiverTransactionBusinessEvent"; + + public WorkingCapitalLoanChargeWaiverTransactionBusinessEvent(final WorkingCapitalLoanTransaction value) { + super(value); + } + + public WorkingCapitalLoanChargeWaiverTransactionBusinessEvent(final WorkingCapitalLoanTransaction value, final Long aggregateRootId) { + super(value, aggregateRootId); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index 7ee8a26fd87..5943c662fa7 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -316,6 +316,8 @@ private GetWorkingCapitalLoanCharge() {} @Schema(example = "0") public BigDecimal amountWrittenOff; @Schema(example = "0") + public BigDecimal amountWaived; + @Schema(example = "0") public BigDecimal amountOutstanding; @Schema(example = "false") public boolean penalty; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResource.java index 10b620d715c..eb46f69d056 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanChargesApiResource.java @@ -124,7 +124,7 @@ public CommandProcessingResult executeLoanCharge( @Path("{loanId}/charges/{loanChargeId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Adjust a Working Capital Loan Charge", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction. Pass command=adjustment.") + @Operation(summary = "Adjust or Waive a Working Capital Loan Charge", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction (command=adjustment), or waives its full outstanding amount by creating a WAIVE_CHARGES transaction (command=waive).") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanChargesApiResourceSwagger.PostWorkingCapitalLoansLoanIdChargesChargeIdResponse.class))) public CommandProcessingResult adjustLoanCharge(@PathParam("loanId") @Parameter(description = "loanId") final Long loanId, @PathParam("loanChargeId") @Parameter(description = "loanChargeId") final Long loanChargeId, @@ -137,7 +137,7 @@ public CommandProcessingResult adjustLoanCharge(@PathParam("loanId") @Parameter( @Path("{loanId}/charges/external-id/{loanChargeExternalId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Adjust a Working Capital Loan Charge by Charge External Id", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction. Pass command=adjustment.") + @Operation(summary = "Adjust or Waive a Working Capital Loan Charge by Charge External Id", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction (command=adjustment), or waives its full outstanding amount by creating a WAIVE_CHARGES transaction (command=waive).") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanChargesApiResourceSwagger.PostWorkingCapitalLoansLoanIdChargesChargeIdResponse.class))) public CommandProcessingResult adjustLoanChargeByChargeExternalId( @PathParam("loanId") @Parameter(description = "loanId") final Long loanId, @@ -151,7 +151,7 @@ public CommandProcessingResult adjustLoanChargeByChargeExternalId( @Path("external-id/{loanExternalId}/charges/{loanChargeId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Adjust a Working Capital Loan Charge by Loan External Id", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction. Pass command=adjustment.") + @Operation(summary = "Adjust or Waive a Working Capital Loan Charge by Loan External Id", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction (command=adjustment), or waives its full outstanding amount by creating a WAIVE_CHARGES transaction (command=waive).") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanChargesApiResourceSwagger.PostWorkingCapitalLoansLoanIdChargesChargeIdResponse.class))) public CommandProcessingResult adjustLoanChargeByLoanExternalId( @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, @@ -165,7 +165,7 @@ public CommandProcessingResult adjustLoanChargeByLoanExternalId( @Path("external-id/{loanExternalId}/charges/external-id/{loanChargeExternalId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Adjust a Working Capital Loan Charge by Loan and Charge External Ids", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction. Pass command=adjustment.") + @Operation(summary = "Adjust or Waive a Working Capital Loan Charge by Loan and Charge External Ids", description = "Adjusts a working capital loan charge by creating a CHARGE_ADJUSTMENT transaction (command=adjustment), or waives its full outstanding amount by creating a WAIVE_CHARGES transaction (command=waive).") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanChargesApiResourceSwagger.PostWorkingCapitalLoansLoanIdChargesChargeIdResponse.class))) public CommandProcessingResult adjustLoanChargeByLoanAndChargeExternalId( @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, @@ -309,12 +309,16 @@ private CommandProcessingResult handleExecuteLoanChargeWithChargeId(final Long l final Long resolvedLoanChargeId = loanChargeId == null ? loanChargeRepository.findIdByExternalId(loanChargeExternalId) : loanChargeId; - if (!CommandParameterUtil.is(commandParam, WorkingCapitalLoanChargeConstants.ADJUSTMENT_LOAN_CHARGE_COMMAND)) { + final CommandWrapperBuilder builder; + if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanChargeConstants.ADJUSTMENT_LOAN_CHARGE_COMMAND)) { + builder = new CommandWrapperBuilder().adjustmentForWorkingCapitalLoanCharge(resolvedLoanId, resolvedLoanChargeId); + } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanChargeConstants.WAIVE_LOAN_CHARGE_COMMAND)) { + builder = new CommandWrapperBuilder().waiveWorkingCapitalLoanCharge(resolvedLoanId, resolvedLoanChargeId); + } else { throw new UnrecognizedQueryParamException("command", commandParam); } - final CommandWrapper commandRequest = new CommandWrapperBuilder() - .adjustmentForWorkingCapitalLoanCharge(resolvedLoanId, resolvedLoanChargeId).withJson(apiRequestBodyAsJson).build(); + final CommandWrapper commandRequest = builder.withJson(apiRequestBodyAsJson).build(); return commandsSourceWritePlatformService.logCommandSource(commandRequest); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanChargeData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanChargeData.java index 60b3dddb8ec..d8d34c4c54a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanChargeData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanChargeData.java @@ -59,6 +59,8 @@ public final class WorkingCapitalLoanChargeData { private final BigDecimal amountWrittenOff; + private final BigDecimal amountWaived; + private final BigDecimal amountOutstanding; private final List chargeOptions; @@ -78,8 +80,8 @@ public final class WorkingCapitalLoanChargeData { public WorkingCapitalLoanChargeData(Long id, Long chargeId, String name, ChargeTimeType chargeTimeType, LocalDate submittedOnDate, LocalDate dueDate, ChargeCalculationType chargeCalculationType, String cCode, String cName, Integer cDecimalPlaces, Integer cInMultiplesOf, String cDisplaySymbol, String cNameCode, BigDecimal amount, BigDecimal amountPaid, - BigDecimal amountWrittenOff, boolean penalty, ChargePaymentMode chargePaymentMode, boolean paid, Long loanId, - ExternalId externalId, ExternalId externalLoanId) { + BigDecimal amountWrittenOff, BigDecimal amountWaived, boolean penalty, ChargePaymentMode chargePaymentMode, boolean paid, + Long loanId, ExternalId externalId, ExternalId externalLoanId) { this.id = id; this.chargeId = chargeId; this.name = name; @@ -91,7 +93,8 @@ public WorkingCapitalLoanChargeData(Long id, Long chargeId, String name, ChargeT this.amount = amount; this.amountPaid = amountPaid; this.amountWrittenOff = amountWrittenOff; - this.amountOutstanding = MathUtil.subtract(amount, amountPaid, amountWrittenOff); + this.amountWaived = amountWaived; + this.amountOutstanding = MathUtil.subtract(amount, amountPaid, amountWrittenOff, amountWaived); this.chargeOptions = null; this.penalty = penalty; this.chargePaymentMode = ChargeEnumerations.chargePaymentMode(chargePaymentMode); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java index e78fe54228f..469953d26b7 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanBalance.java @@ -106,6 +106,18 @@ public class WorkingCapitalLoanBalance extends AbstractAuditableWithUTCDateTimeC @Setter private BigDecimal totalRecovered = BigDecimal.ZERO; + /** + * Waived portions sit beside the written-off buckets rather than in the paid columns: reprocessing rebuilds the + * paid distribution from the payments, so a relief recorded as paid would vanish on the first backdated repayment. + */ + @Column(name = "fee_waived", scale = 6, precision = 19, nullable = false) + @Setter + private BigDecimal feeWaived = BigDecimal.ZERO; + + @Column(name = "penalty_waived", scale = 6, precision = 19, nullable = false) + @Setter + private BigDecimal penaltyWaived = BigDecimal.ZERO; + @Column(name = "realized_income_from_discount_fee", scale = 6, precision = 19, nullable = false) @Setter private BigDecimal realizedIncomeFromDiscountFee = BigDecimal.ZERO; @@ -161,11 +173,11 @@ public BigDecimal getPrincipalOutstanding() { } public BigDecimal getFeeOutstanding() { - return MathUtil.subtract(getFee(), getFeePaid(), getFeeWrittenOff()).max(BigDecimal.ZERO); + return MathUtil.subtract(getFee(), getFeePaid(), getFeeWrittenOff(), getFeeWaived()).max(BigDecimal.ZERO); } public BigDecimal getPenaltyOutstanding() { - return MathUtil.subtract(getPenalty(), getPenaltyPaid(), getPenaltyWrittenOff()).max(BigDecimal.ZERO); + return MathUtil.subtract(getPenalty(), getPenaltyPaid(), getPenaltyWrittenOff(), getPenaltyWaived()).max(BigDecimal.ZERO); } public BigDecimal getTotalOutstanding() { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java index d866947df11..8e55f04c86c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanCharge.java @@ -84,6 +84,9 @@ public class WorkingCapitalLoanCharge extends AbstractAuditableWithUTCDateTimeCu @Column(name = "amount_written_off", scale = 6, precision = 19, nullable = false) private BigDecimal amountWrittenOff = BigDecimal.ZERO; + @Column(name = "amount_waived", scale = 6, precision = 19, nullable = false) + private BigDecimal amountWaived = BigDecimal.ZERO; + @Column(name = "is_penalty", nullable = false) private boolean penaltyCharge = false; @@ -106,14 +109,14 @@ public WorkingCapitalLoanChargeData toData() { return WorkingCapitalLoanChargeData.builder().id(getId()).chargeId(getCharge().getId()).name(getCharge().getName()) .currency(getCharge().toData().getCurrency()).amount(amount).amountPaid(amountPaid).amountWrittenOff(amountWrittenOff) - .amountOutstanding(getAmountOutstanding()).chargeTimeType(chargeTimeTypeData).submittedOnDate(submittedOnDate) - .dueDate(dueDate).chargeCalculationType(chargeCalculationTypeData).penalty(penaltyCharge) + .amountWaived(amountWaived).amountOutstanding(getAmountOutstanding()).chargeTimeType(chargeTimeTypeData) + .submittedOnDate(submittedOnDate).dueDate(dueDate).chargeCalculationType(chargeCalculationTypeData).penalty(penaltyCharge) .chargePaymentMode(chargePaymentModeData).paid(paid).loanId(loan.getId()).externalId(externalId) .externalLoanId(loan.getExternalId()).build(); } public BigDecimal getAmountOutstanding() { - return MathUtil.subtract(getAmount(), getAmountPaid(), getAmountWrittenOff()); + return MathUtil.subtract(getAmount(), getAmountPaid(), getAmountWrittenOff(), getAmountWaived()); } public static WorkingCapitalLoanCharge build(WorkingCapitalLoan loan, ExternalId externalId, Charge charge, BigDecimal amount, @@ -133,6 +136,7 @@ public static WorkingCapitalLoanCharge build(WorkingCapitalLoan loan, ExternalId res.setAmount(amount); res.setAmountPaid(BigDecimal.ZERO); res.setAmountWrittenOff(BigDecimal.ZERO); + res.setAmountWaived(BigDecimal.ZERO); return res; } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanChargeWaiverDomainService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanChargeWaiverDomainService.java new file mode 100644 index 00000000000..e4aa0bac7c9 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanChargeWaiverDomainService.java @@ -0,0 +1,58 @@ +/** + * 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.domain; + +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.springframework.stereotype.Component; + +/** + * Keeps the waiver behaviour off {@link WorkingCapitalLoanCharge} / {@link WorkingCapitalLoanBalance}, which stay data + * holders. Unlike a write-off the buckets are adjusted by amount rather than zeroed on undo: a loan can carry several + * waivers, each reversible on its own. + */ +@Component +public class WorkingCapitalLoanChargeWaiverDomainService { + + /** + * No cap on {@code amount}: the caller captures the charge's outstanding remainder before this call and stores the + * same figure on the waiver transaction. + */ + public void applyWaived(final WorkingCapitalLoanCharge charge, final WorkingCapitalLoanBalance balance, final BigDecimal amount) { + charge.setAmountWaived(MathUtil.add(charge.getAmountWaived(), amount)); + if (charge.isPenaltyCharge()) { + balance.setPenaltyWaived(MathUtil.add(balance.getPenaltyWaived(), amount)); + } else { + balance.setFeeWaived(MathUtil.add(balance.getFeeWaived(), amount)); + } + } + + /** + * The reversed transaction's amount can be taken back out as it stands: a waiver never takes part in reprocessing, + * so what was recorded and what was applied cannot have drifted apart. + */ + public void undoWaive(final WorkingCapitalLoanCharge charge, final WorkingCapitalLoanBalance balance, final BigDecimal amount) { + charge.setAmountWaived(MathUtil.subtractToZero(charge.getAmountWaived(), amount)); + if (charge.isPenaltyCharge()) { + balance.setPenaltyWaived(MathUtil.subtractToZero(balance.getPenaltyWaived(), amount)); + } else { + balance.setFeeWaived(MathUtil.subtractToZero(balance.getFeeWaived(), amount)); + } + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java index 98eea5966d6..be5baf4ac17 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java @@ -245,6 +245,17 @@ public static WorkingCapitalLoanTransaction accrual(final WorkingCapitalLoan loa return transaction; } + /** + * Like a write-off, a waiver moves no money, so it carries no payment detail. The amount is the relieved + * outstanding regardless of how much of it was ever accrued. + */ + public static WorkingCapitalLoanTransaction chargeWaiver(final WorkingCapitalLoan loan, final BigDecimal amount, + final LocalDate transactionDate, final ExternalId externalId) { + final WorkingCapitalLoanTransaction txn = new WorkingCapitalLoanTransaction(); + txn.initialize(loan, LoanTransactionType.WAIVE_CHARGES, transactionDate, amount, null, null, externalId); + return txn; + } + public static WorkingCapitalLoanTransaction writeOff(final WorkingCapitalLoan loan, final BigDecimal amount, final LocalDate transactionDate, final ExternalId externalId) { final WorkingCapitalLoanTransaction txn = new WorkingCapitalLoanTransaction(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java index f56881ed978..bd7b71b9a8b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java @@ -19,6 +19,8 @@ package org.apache.fineract.portfolio.workingcapitalloan.domain; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; @@ -60,4 +62,35 @@ SELECT r.toCharge.id, SUM(r.fromTransaction.transactionAmount) """) List fetchTransactionAmountPerCharge(@Param("wcLoanId") Long wcLoanId, @Param("transactionType") LoanTransactionType transactionType); + + @Query(""" + SELECT COALESCE(SUM(r.fromTransaction.transactionAmount), 0) + FROM WorkingCapitalLoanTransactionRelation r + WHERE r.toCharge = :charge + AND r.fromTransaction.reversed = FALSE + AND r.fromTransaction.transactionType = :transactionType + """) + BigDecimal fetchTransactionAmountForCharge(@Param("charge") WorkingCapitalLoanCharge charge, + @Param("transactionType") LoanTransactionType transactionType); + + /** + * Charge-linked transactions that sort after the given one, split by whether the charge they address is a penalty. + * The order is the replay order of {@link WorkingCapitalLoanTransactionComparator}: the transaction date decides, + * and same-date transactions fall back to the id. The id stands in for the comparator's submitted-on and creation + * keys, which it agrees with as long as the business date does not run backwards: ids come from an identity column, + * and the submitted-on date is the business date the transaction was booked on. + */ + @Query(""" + SELECT COALESCE(SUM(r.fromTransaction.transactionAmount), 0) + FROM WorkingCapitalLoanTransactionRelation r + WHERE r.fromTransaction.wcLoan.id = :wcLoanId + AND r.fromTransaction.transactionType = :transactionType + AND r.fromTransaction.reversed = FALSE + AND r.toCharge.penaltyCharge = :penalty + AND (r.fromTransaction.transactionDate > :afterTransactionDate + OR (r.fromTransaction.transactionDate = :afterTransactionDate AND r.fromTransaction.id > :afterTransactionId)) + """) + BigDecimal sumAmountForChargesSortingAfter(@Param("wcLoanId") Long wcLoanId, + @Param("transactionType") LoanTransactionType transactionType, @Param("afterTransactionDate") LocalDate afterTransactionDate, + @Param("afterTransactionId") Long afterTransactionId, @Param("penalty") boolean penalty); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanChargeWaiverException.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanChargeWaiverException.java new file mode 100644 index 00000000000..ac4bfcf93b4 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanChargeWaiverException.java @@ -0,0 +1,29 @@ +/** + * 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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class WorkingCapitalLoanChargeWaiverException extends AbstractPlatformDomainRuleException { + + public WorkingCapitalLoanChargeWaiverException(final String errorCode, final String defaultUserMessage, + final Object... defaultUserMessageArgs) { + super(errorCode, defaultUserMessage, defaultUserMessageArgs); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanChargeWaiverCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanChargeWaiverCommandHandler.java new file mode 100644 index 00000000000..c36d296a7c4 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanChargeWaiverCommandHandler.java @@ -0,0 +1,43 @@ +/** + * 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.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.domain.CommandWrapperConstants; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanChargeWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = CommandWrapperConstants.ENTITY_WORKINGCAPITALLOANCHARGE, action = CommandWrapperConstants.ACTION_WAIVE) +public class WorkingCapitalLoanChargeWaiverCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanChargeWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return writePlatformService.waiveLoanCharge(command.getLoanId(), command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java index 2edc1c2954e..0d5c8f67297 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanChargeRepository.java @@ -39,14 +39,35 @@ public interface WorkingCapitalLoanChargeRepository List findByLoanIdAndActiveTrueOrderByDueDateAscIdAsc(Long loanId); - @Query("select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData(" - + "lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol," - + " oc.nameCode, lc.amount, lc.amountPaid, lc.amountWrittenOff, lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) from WorkingCapitalLoanCharge lc join fetch lc.charge c join OrganisationCurrency oc on c.currencyCode = oc.code join fetch lc.loan l where l.id = :loanId and lc.id = :id") + @Query(""" + select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData( + lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, + oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol, oc.nameCode, + lc.amount, lc.amountPaid, lc.amountWrittenOff, lc.amountWaived, + lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) + from WorkingCapitalLoanCharge lc + join fetch lc.charge c + join OrganisationCurrency oc on c.currencyCode = oc.code + join fetch lc.loan l + where l.id = :loanId + and lc.id = :id + """) WorkingCapitalLoanChargeData retrieveLoanChargeDetails(@Param("id") Long id, @Param("loanId") Long loanId); - @Query("select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData(" - + "lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol," - + " oc.nameCode, lc.amount, lc.amountPaid, lc.amountWrittenOff, lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) from WorkingCapitalLoanCharge lc join fetch lc.charge c join OrganisationCurrency oc on c.currencyCode = oc.code join fetch lc.loan l where l.id = :loanId and lc.active = true order by lc.chargeTimeType asc, lc.dueDate asc, lc.penaltyCharge asc") + @Query(""" + select new org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData( + lc.id, c.id, c.name, lc.chargeTimeType, lc.submittedOnDate, lc.dueDate, lc.chargeCalculationType, + oc.code, oc.name, oc.decimalPlaces, oc.inMultiplesOf, oc.displaySymbol, oc.nameCode, + lc.amount, lc.amountPaid, lc.amountWrittenOff, lc.amountWaived, + lc.penaltyCharge, lc.chargePaymentMode, lc.paid, l.id, lc.externalId, l.externalId) + from WorkingCapitalLoanCharge lc + join fetch lc.charge c + join OrganisationCurrency oc on c.currencyCode = oc.code + join fetch lc.loan l + where l.id = :loanId + and lc.active = true + order by lc.chargeTimeType asc, lc.dueDate asc, lc.penaltyCharge asc + """) List retrieveLoanCharges(@Param("loanId") Long loanId); /** diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeConstants.java index 68bb4bbef79..ed4f74219d0 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanChargeConstants.java @@ -43,4 +43,7 @@ private WorkingCapitalLoanChargeConstants() {} // Adjustment command public static final String ADJUSTMENT_LOAN_CHARGE_COMMAND = "adjustment"; + + // Waiver command + public static final String WAIVE_LOAN_CHARGE_COMMAND = "waive"; } 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 47baca83cc7..fb8fcd92a21 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 @@ -76,6 +76,23 @@ public void validateChargeAdjustmentRequest(final String json) { throwExceptionIfValidationWarningsExist(dataValidationErrors); } + /** + * An amount is rejected as an unsupported parameter rather than silently ignored - that is what makes "no partial + * waiver option" enforceable. + */ + public void validateChargeWaiverRequest(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Set allowedParameters = new HashSet<>( + Arrays.asList(WorkingCapitalLoanChargeConstants.externalIdParamName, WorkingCapitalLoanChargeConstants.localeParamName, + WorkingCapitalLoanChargeConstants.dateFormatParamName, WorkingCapitalLoanChargeConstants.noteParamName)); + + final Type typeOfMap = new TypeToken>() {}.getType(); + fromJsonHelper.checkForUnsupportedParameters(typeOfMap, json, allowedParameters); + } + public void validateCreateLoanCharge(final String json) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java index 14ab1efe5b4..4961181f605 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeAccrualService.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; @@ -95,14 +96,9 @@ public void accrueOnClosure(final WorkingCapitalLoan loan, final LocalDate closi } /** - * Posts, on early closure, any pending charge accrual that has not been recognized yet. Once the loan is closed it - * is no longer picked up by the end-of-day job, so the accrual is accelerated to the closing date to make sure the - * income is recognized. This runs regardless of the {@code charge-accrual-date} mode: the idempotency guard skips - * charges already accrued (the common case in submitted-date mode, where charges are accrued when added), while - * charges that slipped through every accrual step are caught here. That gap is real when the mode changes between - * the charge being added and the loan closing: a charge added under due-date with a future due date is never - * accrued on add, never reached by the due-date COB step, and would be missed at closure if this were gated on the - * mode. + * A closed loan is no longer picked up by the end-of-day job, so any accrual it still owes is accelerated to the + * closing date. Not gated on the {@code charge-accrual-date} mode: under due-date a charge the loan closed before + * reaching was accrued by nothing, and a charge that already reached its target is left alone anyway. */ public void processClosureAccruals(final WorkingCapitalLoan loan, final LocalDate closingDate) { if (isAccrualPostingDisabled(loan)) { @@ -118,14 +114,20 @@ private boolean isAccrualPostingDisabled(final WorkingCapitalLoan loan) { private void createChargeAccrualIfMissing(final WorkingCapitalLoan loan, final WorkingCapitalLoanCharge charge, final LocalDate accrualDate) { - // The accrual recognizes the full charge income regardless of whether the charge was already paid or adjusted; - // gating on the outstanding amount would skip fully-settled charges and leave income unrecognized (and the - // receivable/income pair un-netted). Waived charges are excluded upstream via the active-charge filter. - if (accrualDate == null || isAlreadyAccrued(charge) || !MathUtil.isGreaterThanZero(charge.getAmount())) { + // Not gated on the outstanding amount: that would skip fully-settled charges and leave their income + // unrecognized, with the receivable/income pair un-netted. Only the waived part is left out of the target: + // nothing further is recognized on a debt the borrower no longer owes. Income recognized before the waiver + // is not unwound here. + // + // Chasing a target rather than gating on "already accrued": undoing a waiver raises the target again, so the + // next run tops the recognized income back up instead of leaving it unrecognized forever. + final BigDecimal target = MathUtil.subtractToZero(charge.getAmount(), charge.getAmountWaived()); + final BigDecimal toAccrue = MathUtil.subtractToZero(target, getAccruedAmount(charge)); + if (accrualDate == null || !MathUtil.isGreaterThanZero(toAccrue)) { return; } - final WorkingCapitalLoanTransaction accrualTransaction = WorkingCapitalLoanTransaction.accrual(loan, ExternalId.empty(), - charge.getAmount(), accrualDate); + final WorkingCapitalLoanTransaction accrualTransaction = WorkingCapitalLoanTransaction.accrual(loan, ExternalId.empty(), toAccrue, + accrualDate); final WorkingCapitalLoanTransactionRelation relation = WorkingCapitalLoanTransactionRelation.linkToCharge(accrualTransaction, charge, LoanTransactionRelationTypeEnum.RELATED); accrualTransaction.getLoanTransactionRelations().add(relation); @@ -133,7 +135,7 @@ private void createChargeAccrualIfMissing(final WorkingCapitalLoan loan, final W transactionRepository.saveAndFlush(accrualTransaction); final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation - .forChargeAccrual(accrualTransaction, charge.getAmount(), charge.isPenaltyCharge()); + .forChargeAccrual(accrualTransaction, toAccrue, charge.isPenaltyCharge()); allocationRepository.saveAndFlush(allocation); accountingProcessor.postJournalEntries(loan, accrualTransaction, allocation, transactionFinder.isAfterActiveChargeOffForAccountingRouting(loan, accrualTransaction)); @@ -142,10 +144,8 @@ private void createChargeAccrualIfMissing(final WorkingCapitalLoan loan, final W .notifyPostBusinessEvent(new WorkingCapitalLoanAccrualTransactionBusinessEvent(accrualTransaction, loan.getId())); } - private boolean isAlreadyAccrued(final WorkingCapitalLoanCharge charge) { - return !relationRepository - .findAllByToChargeAndFromTransactionReversedAndFromTransactionTransactionType(charge, false, LoanTransactionType.ACCRUAL) - .isEmpty(); + public BigDecimal getAccruedAmount(final WorkingCapitalLoanCharge charge) { + return MathUtil.nullToZero(relationRepository.fetchTransactionAmountForCharge(charge, LoanTransactionType.ACCRUAL)); } private String retrieveChargeAccrualDateConfig() { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformService.java index a2cdfc360bf..f2c0efc9fc5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformService.java @@ -27,4 +27,6 @@ public interface WorkingCapitalLoanChargeWritePlatformService { CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command); CommandProcessingResult adjustmentForLoanCharge(Long loanId, Long wcLoanChargeId, JsonCommand command); + + CommandProcessingResult waiveLoanCharge(Long loanId, Long wcLoanChargeId, JsonCommand command); } 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 b277bc4245d..62529a22018 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 @@ -27,6 +27,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -43,6 +44,7 @@ import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanBalanceChangedBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanStatusChangedBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentTransactionBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeWaiverTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.charge.domain.Charge; @@ -55,6 +57,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationPlan; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanAllocationRequest; @@ -62,15 +65,18 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanChargePaidBy; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanChargeWaiverDomainService; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionFinder; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelation; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanChargeAdjustmentException; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanChargeNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanChargeWaiverException; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanChargePaidByRepository; @@ -111,6 +117,13 @@ public class WorkingCapitalLoanChargeWritePlatformServiceImpl implements Working private final WorkingCapitalLoanBreachScheduleService breachScheduleService; private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper; private final WorkingCapitalLoanChargeAccrualService chargeAccrualService; + private final WorkingCapitalLoanChargeWaiverDomainService chargeWaiverDomainService; + // Needed directly because a waiver, unlike a charge adjustment, is not repayment-like: it never reaches + // processRepaymentLikeTransaction, which would otherwise post its journal entries and, when the waiver lands + // before an active charge-off, hand the loan to reprocessing. + private final WorkingCapitalLoanAccountingProcessor accountingProcessor; + private final WorkingCapitalLoanTransactionFinder transactionFinder; + private final WorkingCapitalLoanTransactionReprocessingService transactionReprocessingService; @Transactional @Override @@ -391,6 +404,155 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final .build(); } + /** + * Waives the whole unpaid remainder of a single charge. + * + *

+ * The relief lands in a waived bucket instead of going through the payment allocation order, so it is not mistaken + * for a payment and survives reprocessing, which resets only the paid distribution. + *

+ * + *

+ * The allocation carries the recognized part of the waiver, which can fall short of its full amount: income that + * was never accrued has no receivable to credit. Same split the term-loan waiver makes. + *

+ */ + @Transactional + @Override + public CommandProcessingResult waiveLoanCharge(final Long loanId, final Long wcLoanChargeId, final JsonCommand command) { + loanChargeDataValidator.validateChargeWaiverRequest(command.json()); + + final WorkingCapitalLoan loan = workingCapitalLoanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + final WorkingCapitalLoanCharge wcCharge = loanChargeRepository.findById(wcLoanChargeId) + .orElseThrow(() -> new WorkingCapitalLoanChargeNotFoundException(wcLoanChargeId)); + + if (wcCharge.getLoan() == null || !loanId.equals(wcCharge.getLoan().getId())) { + throw new WorkingCapitalLoanChargeWaiverException("wc.loan.charge.waiver.charge.not.belongs.to.loan", + "Working capital loan charge " + wcLoanChargeId + " does not belong to loan " + loanId); + } + + chargeWaiverEntranceValidation(loan, wcCharge); + + // The balance row exists on every status the waiver is allowed on, so there is nothing to create here. Read + // after the status check, so an undisbursed loan is rejected for its status rather than for the missing row. + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loanId) + .orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.wc.loan.balance.not.found", + "No balance found for Working Capital Loan " + loanId, loanId)); + + final LocalDate transactionDate = resolveWaiverTransactionDate(wcCharge); + final BigDecimal waivedAmount = wcCharge.getAmountOutstanding(); + final ExternalId externalId = externalIdFactory.createFromCommand(command, WorkingCapitalLoanChargeConstants.externalIdParamName); + + final Map changes = new LinkedHashMap<>(); + changes.put(WorkingCapitalLoanChargeConstants.transactionDateParamName, transactionDate); + changes.put(WorkingCapitalLoanChargeConstants.externalIdParamName, externalId); + + final WorkingCapitalLoanTransaction waiverTx = WorkingCapitalLoanTransaction.chargeWaiver(loan, waivedAmount, transactionDate, + externalId); + final WorkingCapitalLoanTransactionRelation relation = WorkingCapitalLoanTransactionRelation.linkToCharge(waiverTx, wcCharge, + LoanTransactionRelationTypeEnum.RELATED); + waiverTx.getLoanTransactionRelations().add(relation); + transactionRepository.saveAndFlush(waiverTx); + + final BigDecimal recognizedPortion = calculateRecognizedWaiverPortion(wcCharge, waivedAmount); + final BigDecimal feePortion = wcCharge.isPenaltyCharge() ? BigDecimal.ZERO : recognizedPortion; + final BigDecimal penaltyPortion = wcCharge.isPenaltyCharge() ? recognizedPortion : BigDecimal.ZERO; + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation.forPortions(waiverTx, + BigDecimal.ZERO, feePortion, penaltyPortion, BigDecimal.ZERO); + allocationRepository.saveAndFlush(allocation); + + chargeWaiverDomainService.applyWaived(wcCharge, balance, waivedAmount); + loanChargeRepository.saveAndFlush(wcCharge); + balanceRepository.saveAndFlush(balance); + + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + accountingProcessor.postJournalEntries(loan, waiverTx, allocation, + transactionFinder.isAfterActiveChargeOffForAccountingRouting(loan, waiverTx)); + } + + // A waiver dated to a past due date can land before an active charge-off, which then wrote off a fee the + // borrower no longer owed as of that date. Replaying restates the charge-off's snapshot and everything routed + // off it - the same gate a backdated repayment passes in processRepaymentLikeTransaction. + if (transactionFinder.isBeforeActiveChargeOff(loan, waiverTx)) { + transactionReprocessingService.reprocessTransactions(loan); + } + + final LoanStatus oldStatus = loan.getLoanStatus(); + stateMachine.determineAndTransition(loan, transactionDate); + transactionProcessor.recalculateOverpaidOnDate(loan, waiverTx); + workingCapitalLoanRepository.saveAndFlush(loan); + + // No-ops while the loan is still active. A waiver that cleared the last outstanding amount closes it, and a + // closed loan leaves the COB scope, so both would otherwise never run. + transactionProcessor.triggerInlineAmortizationIfLoanClosed(loan, transactionDate); + chargeAccrualService.accrueOnClosure(loan, transactionDate); + + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanChargeConstants.noteParamName); + if (StringUtils.isNotBlank(noteText)) { + noteRepository.save(WorkingCapitalLoanNote.create(loan, noteText)); + changes.put(WorkingCapitalLoanChargeConstants.noteParamName, noteText); + } + + businessEventNotifierService + .notifyPostBusinessEvent(new WorkingCapitalLoanChargeWaiverTransactionBusinessEvent(waiverTx, loan.getId())); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(wcLoanChargeId) // + .withEntityExternalId(wcCharge.getExternalId()) // + .withSubEntityId(waiverTx.getId()) // + .withSubEntityExternalId(waiverTx.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); + } + + /** + * A past due date wins over the business date, mirroring the term-loan waiver: the relief is dated to the period + * the obligation fell due in. With no due date, or one still ahead, the business date is all that is left. + */ + private LocalDate resolveWaiverTransactionDate(final WorkingCapitalLoanCharge wcCharge) { + final LocalDate businessDate = ThreadLocalContextUtil.getBusinessDate(); + return Optional.ofNullable(wcCharge.getDueDate()).filter(dueDate -> dueDate.isBefore(businessDate)).orElse(businessDate); + } + + /** + * Only the part with a receivable behind it may reach the ledger: what the charge accrued, less what a payment + * already consumed of it. The rest is income that was never recognized, so there is nothing to reverse. + */ + private BigDecimal calculateRecognizedWaiverPortion(final WorkingCapitalLoanCharge wcCharge, final BigDecimal waivedAmount) { + final BigDecimal accruedAmount = chargeAccrualService.getAccruedAmount(wcCharge); + final BigDecimal receivableCharge = MathUtil.subtractToZero(accruedAmount, wcCharge.getAmountPaid()); + return MathUtil.min(waivedAmount, receivableCharge, true); + } + + private void chargeWaiverEntranceValidation(final WorkingCapitalLoan loan, final WorkingCapitalLoanCharge wcCharge) { + // A charged-off loan passes deliberately: charge-off is a flag, not a status, and waiving an existing charge + // on one stays allowed (see createLoanCharge). CLOSED_WRITTEN_OFF is out - its charges already moved into the + // written-off bucket. + if (!loan.isOpen() && !loan.isClosedObligationsMet() && !loan.isOverpaid()) { + throw new WorkingCapitalLoanChargeWaiverException("wc.loan.charge.waiver.invalid.status", + "Charge waiver is not supported for the status of " + loan.getLoanStatus()); + } + + if (!wcCharge.isActive()) { + throw new WorkingCapitalLoanChargeWaiverException("wc.loan.charge.waiver.inactive.charge", + "Charge waiver is not supported for inactive charges"); + } + + if (!MathUtil.isGreaterThanZero(wcCharge.getAmountOutstanding())) { + throw new WorkingCapitalLoanChargeWaiverException("wc.loan.charge.waiver.no.outstanding.amount", + "Charge " + wcCharge.getId() + " has no outstanding amount to waive"); + } + + checkClientActive(loan); + } + private void chargeAdjustmentEntranceValidation(final WorkingCapitalLoan loan, final WorkingCapitalLoanCharge wcCharge, final BigDecimal amount) { if (!loan.isOpen() && !loan.isClosedObligationsMet() && !loan.isOverpaid()) { @@ -422,7 +584,10 @@ private BigDecimal calculateAvailableAmountForChargeAdjustment(final WorkingCapi .findAllByToChargeAndFromTransactionReversedAndFromTransactionTransactionType(wcCharge, false, LoanTransactionType.CHARGE_ADJUSTMENT) .stream().map(rel -> rel.getFromTransaction().getTransactionAmount()).reduce(BigDecimal.ZERO, BigDecimal::add); - return wcCharge.getAmount().subtract(previouslyAdjusted); + // The waived part is subtracted too: without it an adjustment on a fully waived charge would pass this check + // and then hit a charge the allocator caps at a zero outstanding, quietly settling principal instead. The + // written-off part needs no such guard - a write-off is terminal, so no adjustment can follow it. + return MathUtil.subtract(wcCharge.getAmount(), previouslyAdjusted, wcCharge.getAmountWaived()); } private void checkClientActive(final WorkingCapitalLoan loan) { 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 6dd5866a607..f83dc9acb8c 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 @@ -405,10 +405,20 @@ private boolean replayChargeOff(final WorkingCapitalLoan loan, final WorkingCapi final WorkingCapitalLoanTransaction chargeOffTransaction, final boolean accountingEnabled, final List updatedAllocations, final List adjustedTransactions) { - final BigDecimal chargeOffAmount = balance.getTotalOutstanding(); + // A waiver that sorts after the charge-off is not part of what that charge-off had to write off, but its + // buckets are in the balance all the same - they survive the reset that rebuilds the paid distribution. Adding + // them back restores the outstanding as of the charge-off's own position in the replay, which is the order the + // waiver's journal entries are routed on too. Waivers sorting before it stay subtracted: they had already + // reduced the outstanding when the charge-off was first booked. + final BigDecimal feeWaivedAfterChargeOff = transactionRelationRepository.sumAmountForChargesSortingAfter(loan.getId(), + LoanTransactionType.WAIVE_CHARGES, chargeOffTransaction.getTransactionDate(), chargeOffTransaction.getId(), false); + final BigDecimal penaltyWaivedAfterChargeOff = transactionRelationRepository.sumAmountForChargesSortingAfter(loan.getId(), + LoanTransactionType.WAIVE_CHARGES, chargeOffTransaction.getTransactionDate(), chargeOffTransaction.getId(), true); + final BigDecimal principalPortion = balance.getPrincipalOutstanding(); - final BigDecimal feePortion = balance.getFeeOutstanding(); - final BigDecimal penaltyPortion = balance.getPenaltyOutstanding(); + final BigDecimal feePortion = MathUtil.add(balance.getFeeOutstanding(), feeWaivedAfterChargeOff); + final BigDecimal penaltyPortion = MathUtil.add(balance.getPenaltyOutstanding(), penaltyWaivedAfterChargeOff); + final BigDecimal chargeOffAmount = MathUtil.add(principalPortion, feePortion, penaltyPortion); final BigDecimal overpaymentPortion = MathUtil.nullToZero(balance.getOverpaymentAmount()); if (!MathUtil.isGreaterThanZero(chargeOffAmount)) { 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 e0ede346b47..b7e4dd6e0ee 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 @@ -26,6 +26,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; @@ -79,6 +80,7 @@ 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.WorkingCapitalLoanChargeWaiverDomainService; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; @@ -135,6 +137,7 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanTransactionProcessor transactionProcessor; private final WorkingCapitalLoanChargeAccrualService chargeAccrualService; private final WorkingCapitalLoanTransactionFinder transactionFinder; + private final WorkingCapitalLoanChargeWaiverDomainService chargeWaiverDomainService; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -719,6 +722,7 @@ public CommandProcessingResult undoTransaction(final Long loanId, final Long tra // A recovery payment never entered the balance, so the generic undo (which rewinds an allocation and // replays the schedule) does not apply: it has its own reversal, allowed while the loan is written off. case RECOVERY_REPAYMENT -> recoveryPaymentWriteService.undoRecoveryPayment(loan, transaction, command); + case WAIVE_CHARGES -> undoChargeWaiver(loan, transaction, command); case REPAYMENT, GOODWILL_CREDIT, CHARGE_ADJUSTMENT, PAYOUT_REFUND -> 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(), @@ -726,6 +730,62 @@ public CommandProcessingResult undoTransaction(final Long loanId, final Long tra }; } + /** + * Deliberately not routed through the generic {@code undoTransaction}: none of its branches gives the waived bucket + * back, and the two that rewind a balance reach {@code updateBalanceAfterUndo}, which throws on any type outside + * the money-movers it knows. + */ + private CommandProcessingResult undoChargeWaiver(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction waiverTransaction, + final JsonCommand command) { + validator.validateUndoTransaction(command, loan, waiverTransaction); + + reverseTransaction(waiverTransaction); + + final WorkingCapitalLoanCharge charge = waiverTransaction.getLoanTransactionRelations().stream() + .map(WorkingCapitalLoanTransactionRelation::getToCharge).filter(Objects::nonNull).findFirst() + .orElseThrow(() -> new IllegalStateException( + "Charge waiver transaction " + waiverTransaction.getId() + " is missing its link to the waived charge")); + final WorkingCapitalLoanBalance balance = balanceRepository.findByWcLoan_Id(loan.getId()) + .orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.wc.loan.balance.not.found", + "No balance found for Working Capital Loan " + loan.getId(), loan.getId())); + + chargeWaiverDomainService.undoWaive(charge, balance, waiverTransaction.getTransactionAmount()); + chargeRepository.saveAndFlush(charge); + balanceRepository.saveAndFlush(balance); + + // Giving the waived amount back raises the outstanding the charge-off snapshot is built from, so the + // charge-off has to cover the restored charge again. Mirrors undoDiscountFeeAdjustment. + if (loan.isChargedOff()) { + transactionReprocessingService.reprocessTransactions(loan); + } + + final LocalDate reversedOnDate = waiverTransaction.getReversedOnDate(); + final LoanStatus oldStatus = loan.getLoanStatus(); + stateMachine.determineAndTransition(loan, reversedOnDate); + transactionProcessor.recalculateOverpaidOnDate(loan, waiverTransaction); + loanRepository.saveAndFlush(loan); + + if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + accountingProcessor.postReversalJournalEntries(loan, waiverTransaction); + } + + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + createNote(noteText, loan); + + adjustTransactionEventPublisher.publishReversal(loan.getId(), waiverTransaction); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + + final Map changes = new LinkedHashMap<>(); + changes.put("status", loan.getLoanStatus()); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(waiverTransaction.getId()) + .withEntityExternalId(waiverTransaction.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/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 0f40db61dd2..3e00aa2d3b9 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -101,4 +101,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_loan_charge_waiver.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_loan_charge_waiver.xml new file mode 100644 index 00000000000..7c223553382 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_loan_charge_waiver.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) + FROM m_external_event_configuration + WHERE type = 'WorkingCapitalLoanChargeWaiverTransactionBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_permission + WHERE code = 'WAIVE_WORKINGCAPITALLOANCHARGE' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission + WHERE code = 'WAIVE_WORKINGCAPITALLOANCHARGE_CHECKER' + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java index 21d10ab2a06..824af243c1b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/ExternalEventConfigurationTestData.java @@ -167,6 +167,7 @@ public final class ExternalEventConfigurationTestData { "WorkingCapitalLoanChargeAdjustmentPreBusinessEvent", // "WorkingCapitalLoanChargeAdjustmentPostBusinessEvent", // "WorkingCapitalLoanChargeAdjustmentTransactionBusinessEvent", // + "WorkingCapitalLoanChargeWaiverTransactionBusinessEvent", // "WorkingCapitalLoanCreatedBusinessEvent", // "WorkingCapitalLoanApplicationModifiedBusinessEvent", // "WorkingCapitalLoanApprovedBusinessEvent", //