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 9bfe1f0606e..f4a0dc6e19d 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 @@ -863,6 +863,15 @@ public CommandWrapperBuilder discountFeeAdjustmentWorkingCapitalLoanTransaction( return this; } + public CommandWrapperBuilder undoWorkingCapitalLoanTransaction(final Long loanId, final Long transactionId) { + this.actionName = ACTION_UNDO; + this.entityName = ENTITY_WORKINGCAPITALLOAN; + this.entityId = transactionId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/transactions/" + transactionId + "?command=undo"; + return this; + } + public CommandWrapperBuilder createWorkingCapitalLoanDelinquencyAction(final Long workingCapitalLoanId) { this.actionName = "CREATE"; this.entityName = "WC_DELINQUENCY_ACTION"; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index 71bbad672ea..2fb1ceb0415 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -1118,10 +1118,6 @@ public static String discountAdjustmentExceedFailure() { return "Failed data validation due to: cannot.be.more.than.discount.fee."; } - public static String discountAdjustmentBackdatedFailure() { - return "Failed data validation due to: backdated.not.allowed."; - } - public static String discountAdjustmentBeforeDiscountDateFailure() { return "Failed data validation due to: cannot.be.before.discount.fee.date."; } @@ -1138,6 +1134,22 @@ public static String discountAdjustmentNotActiveLoanFailure() { return "Failed data validation due to: adjustment.only.allowed.for.active.loan."; } + public static String discountAdjustmentUndoAlreadyReversedFailure() { + return "Failed data validation due to: discount.adjustment.already.reversed."; + } + + public static String discountAdjustmentUndoInvalidTypeFailure() { + return "Undo is not supported for transaction type"; + } + + public static String discountAdjustmentUndoTransactionNotFoundFailure() { + return "Working capital loan transaction not found"; + } + + public static String discountAdjustmentUndoNotActiveLoanFailure() { + return "Failed data validation due to: undo.discount.adjustment.only.allowed.for.active.loan."; + } + public static String nearBreachCannotEnableWithoutBreachFailure() { return "Failed data validation due to: cannot.enable.near.breach.without.breach."; } 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 2fd38565937..15365305a40 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 @@ -41,6 +41,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1514,6 +1515,141 @@ public void loadDiscountFeeTransactionFromLoanForAdjustment() { testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_DISCOUNT_FEE_RESPONSE, synthetic); } + @When("Admin undo the last Discount fee adjustment on Working Capital loan account") + public void undoLastDiscountFeeAdjustmentWCLoan() { + final Long loanId = getCreatedLoanId(); + final GetWorkingCapitalLoanTransactionsResponse body = ok( + () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); + if (body.getContent() == null || body.getContent().isEmpty()) { + throw new IllegalStateException("No Working Capital Loan transactions found"); + } + final GetWorkingCapitalLoanTransactionIdResponse adjustmentTxn = body.getContent().stream() + .filter(t -> t.getType() != null && "loanTransactionType.discountFeeAdjustment".equals(t.getType().getCode())) + .filter(t -> !Boolean.TRUE.equals(t.getReversed())) + .max(Comparator.comparing(GetWorkingCapitalLoanTransactionIdResponse::getId)) + .orElseThrow(() -> new IllegalStateException("Active discount fee adjustment transaction not found on loan")); + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest(); + ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionCommandById(loanId, + adjustmentTxn.getId(), "undo", request)); + } + + @When("Admin undo the Discount fee adjustment with {string} amount on Working Capital loan account") + public void undoDiscountFeeAdjustmentByAmountWCLoan(final String adjustmentAmount) { + final Long loanId = getCreatedLoanId(); + final GetWorkingCapitalLoanTransactionsResponse body = ok( + () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); + if (body.getContent() == null || body.getContent().isEmpty()) { + throw new IllegalStateException("No Working Capital Loan transactions found"); + } + + final BigDecimal amount = new BigDecimal(adjustmentAmount); + final GetWorkingCapitalLoanTransactionIdResponse adjustmentTxn = body.getContent().stream().filter(t -> t.getType() != null) + .filter(t -> "loanTransactionType.discountFeeAdjustment".equals(t.getType().getCode())) + .filter(t -> !Boolean.TRUE.equals(t.getReversed())).filter(t -> t.getTransactionAmount() != null) + .filter(t -> t.getTransactionAmount().compareTo(amount) == 0) + .max(Comparator.comparing(GetWorkingCapitalLoanTransactionIdResponse::getId)).orElseThrow(() -> new IllegalStateException( + "Active discount fee adjustment transaction with amount " + adjustmentAmount + " not found on loan")); + + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest(); + + ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionCommandById(loanId, + adjustmentTxn.getId(), "undo", request)); + } + + @Then("Undo the last Discount fee adjustment on Working Capital loan account failed due to already reversed transaction with status code {int}") + public void undoLastDiscountFeeAdjustmentAlreadyReversedFailure(final int expectedStatus) { + final Long loanId = getCreatedLoanId(); + final GetWorkingCapitalLoanTransactionsResponse body = ok( + () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); + if (body.getContent() == null || body.getContent().isEmpty()) { + throw new IllegalStateException("No Working Capital Loan transactions found"); + } + + final GetWorkingCapitalLoanTransactionIdResponse adjustmentTxn = body.getContent().stream().filter(t -> t.getType() != null) + .filter(t -> "loanTransactionType.discountFeeAdjustment".equals(t.getType().getCode())) + .max(Comparator.comparing(GetWorkingCapitalLoanTransactionIdResponse::getId)) + .orElseThrow(() -> new IllegalStateException("Discount fee adjustment transaction not found on loan")); + + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest(); + + final String errorMessage = ErrorMessageHelper.discountAdjustmentUndoAlreadyReversedFailure(); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionCommandById(loanId, adjustmentTxn.getId(), "undo", request)); + + assertThat(exception.getStatus()).as(errorMessage).isEqualTo(expectedStatus); + + assertThat(exception.getDeveloperMessage()).contains(errorMessage); + } + + @Then("Undo discount fee adjustment referencing the discount fee transaction on Working Capital loan account failed due to invalid transaction type with status code {int}") + public void undoDiscountFeeAdjustmentInvalidTypeFailure(final int expectedStatus) { + final Long loanId = getCreatedLoanId(); + final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_DISCOUNT_FEE_RESPONSE); + + Assertions.assertNotNull(lastDiscountResponse); + + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest(); + + final String errorMessage = ErrorMessageHelper.discountAdjustmentUndoInvalidTypeFailure(); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionCommandById(loanId, lastDiscountResponse.getResourceId(), "undo", request)); + + assertThat(exception.getStatus()).as(errorMessage).isEqualTo(expectedStatus); + + assertThat(exception.getDeveloperMessage()).contains(errorMessage); + } + + @Then("Undo discount fee adjustment with a non-existent transaction id on Working Capital loan account failed as not found with status code {int}") + public void undoDiscountFeeAdjustmentNotFoundFailure(final int expectedStatus) { + final Long loanId = getCreatedLoanId(); + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest(); + + final String errorMessage = ErrorMessageHelper.discountAdjustmentUndoTransactionNotFoundFailure(); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionCommandById(loanId, 999999999L, "undo", request)); + + assertThat(exception.getStatus()).as(errorMessage).isEqualTo(expectedStatus); + + assertThat(exception.getDeveloperMessage()).contains(errorMessage); + } + + @Then("Undo the last Discount fee adjustment on Working Capital loan account failed due to non active loan with status code {int}") + public void undoLastDiscountFeeAdjustmentNotActiveLoanFailure(final int expectedStatus) { + final Long loanId = getCreatedLoanId(); + final GetWorkingCapitalLoanTransactionsResponse body = ok( + () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); + if (body.getContent() == null || body.getContent().isEmpty()) { + throw new IllegalStateException("No Working Capital Loan transactions found"); + } + + final GetWorkingCapitalLoanTransactionIdResponse adjustmentTxn = body.getContent().stream().filter(t -> t.getType() != null) + .filter(t -> "loanTransactionType.discountFeeAdjustment".equals(t.getType().getCode())) + .filter(t -> !Boolean.TRUE.equals(t.getReversed())) + .max(Comparator.comparing(GetWorkingCapitalLoanTransactionIdResponse::getId)) + .orElseThrow(() -> new IllegalStateException("Active discount fee adjustment transaction not found on loan")); + + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest(); + + final String errorMessage = ErrorMessageHelper.discountAdjustmentUndoNotActiveLoanFailure(); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionCommandById(loanId, adjustmentTxn.getId(), "undo", request)); + + assertThat(exception.getStatus()).as(errorMessage).isEqualTo(expectedStatus); + + assertThat(exception.getDeveloperMessage()).contains(errorMessage); + } + @And("Add Discount fee adjustment with {string} amount on Working Capital loan account failed due to exceeding discount amount") public void addDiscountFeeAdjustmentExceededFailure(final String adjustmentAmount) { addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, null, ErrorMessageHelper.discountAdjustmentExceedFailure()); @@ -1530,11 +1666,6 @@ public void addDiscountFeeAdjustmentFutureDateFailure(final String adjustmentAmo addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, transactionDate, ErrorMessageHelper.discountAdjustmentFutureDateFailure()); } - @Then("Add Discount fee adjustment with {string} amount and transaction date {string} on Working Capital loan account failed due to backdated transaction date") - public void addDiscountFeeAdjustmentBackdatedFailure(final String adjustmentAmount, final String transactionDate) { - addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, transactionDate, ErrorMessageHelper.discountAdjustmentBackdatedFailure()); - } - @Then("Add Discount fee adjustment with {string} amount and transaction date {string} on Working Capital loan account failed as amount must be greater then zero") public void addDiscountFeeAdjustmentZeroAmountFailure(final String adjustmentAmount, final String transactionDate) { addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, transactionDate, ErrorMessageHelper.discountAdjustmentZeroAmountFailure()); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity-Part2.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity-Part2.feature index 3406ddc757c..3ad01303ebc 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity-Part2.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity-Part2.feature @@ -4101,3 +4101,13 @@ Feature: LoanAccrualActivity - Part2 When Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "21 July 2026" with 300 EUR transaction amount and system-generated Idempotency key Then Loan status will be "OVERPAID" Then LoanAccrualAdjustmentTransactionBusinessEvent is raised on "20 July 2026" + # post-due-date Accrual is PRESERVED (not bare-reversed) + Then Loan Transactions tab has a transaction with date: "20 July 2026", and with the following data: + | Transaction Type | Amount | Interest | Reverted | + | Accrual | 19.75 | 19.75 | false | + # ...and its effect is cancelled by a visible ACCRUAL_ADJUSTMENT (the fix), instead of a hidden reversal. + Then Loan Transactions tab has a transaction with date: "20 July 2026", and with the following data: + | Transaction Type | Amount | Interest | Reverted | + | Accrual Adjustment | 19.75 | 19.75 | false | + # Net recognised interest income is unchanged + Then Loan has 19.75 total Accruals diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature index 7eccccca9e4..d8fade3e787 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustment.feature @@ -80,28 +80,6 @@ Feature: Working Capital Discount Adjustment | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false | | 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false | - @TestRailId:C83028 - Scenario: Verify Discount fee adjustment fails when transaction date is before business date - UC5 - When Admin sets the business date to "01 January 2026" - And Admin creates a client with random data - And Admin creates a working capital loan with the following data: - | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | - | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | | - Then Working capital loan creation was successful - Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" - Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount - Then Admin adds Discount fee with "12" amount on Working Capital loan account for last disbursement - And Working Capital Loan has transactions: - | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | - | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false | - | 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false | - When Admin sets the business date to "20 January 2026" - Then Add Discount fee adjustment with "2" amount and transaction date "15 January 2026" on Working Capital loan account failed due to backdated transaction date - And Working Capital Loan has transactions: - | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | - | 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false | - | 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false | - @TestRailId:C83029 Scenario: Verify Discount fee adjustment fails with transaction future date - UC6 When Admin sets the business date to "01 January 2026" diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustmentBackdateUndo.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustmentBackdateUndo.feature new file mode 100644 index 00000000000..4aca9912879 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscountAdjustmentBackdateUndo.feature @@ -0,0 +1,461 @@ +@WorkingCapitalDiscountAdjustmentBackdatedUndoFeature +Feature: Working Capital Discount Adjustment Backdated and Undo + + @TestRailId:C83061 + Scenario: Backdated discount adjustment keeps the principal already repaid before the adjustment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Customer makes repayment on "03 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "04 January 2026" + And Admin adds Discount fee adjustment with "1000" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | + | 0.0 | 9000.0 | 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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + + @TestRailId:C83062 + Scenario: Undo of a backdated discount adjustment restores the discount and reverses the adjustment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "08 January 2026" + And Admin adds Discount fee adjustment with "1000" amount on transaction date "05 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 0.0 | 9000.0 | + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | + | 1000.0 | 10000.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | true | + + @TestRailId:C83063 + Scenario: Partial discount adjustment can be backdated + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + And Admin adds Discount fee adjustment with "400" amount on transaction date "03 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 600.0 | 9600.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Adjustment | 400.0 | 400.0 | 0.0 | 0.0 | false | + + @TestRailId:C83065 + Scenario: Backdated discount adjustment posts amortization adjustment as of current business date for income recognized up to the previous COB + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Customer makes repayment on "03 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "04 January 2026" + And Customer makes repayment on "04 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "06 January 2026" + And Customer makes repayment on "06 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "07 January 2026" + When Admin runs inline COB job for Working Capital Loan + And Admin adds Discount fee adjustment with "1000" amount on transaction date "05 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | + | 0.0 | 9000.0 | 250.0 | + When Admin sets the business date to "08 January 2026" + When Admin runs inline COB job for Working Capital Loan + 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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 06 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 06 January 2026 | Discount Fee Amortization | 47.62 | | | | false | + | 07 January 2026 | Discount Fee Amortization Adjustment | 47.62 | | | | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT" transaction with date "07 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | 47.62 | | + | LIABILITY | 240005 | Deferred Interest Revenue | | 47.62 | + + @TestRailId:C83066 + Scenario: Full discount adjustment posts an amortization adjustment on the next COB; undo reverses both the discount adjustment and the amortization adjustment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Customer makes repayment on "03 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "04 January 2026" + And Customer makes repayment on "04 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + When Admin runs inline COB job for Working Capital Loan + And Admin adds Discount fee adjustment with "1000" amount on transaction date "05 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | + | 0.0 | 9000.0 | 150.0 | + When Admin sets the business date to "06 January 2026" + When Admin runs inline COB job for Working Capital Loan + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT" transaction with date "05 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | 28.70 | | + | LIABILITY | 240005 | Deferred Interest Revenue | | 28.70 | + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | + | 1000.0 | 10000.0 | 150.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Discount Fee Amortization | 28.70 | | | | false | + | 05 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | true | + | 05 January 2026 | Discount Fee Amortization Adjustment | 28.70 | | | | true | + Then Working Capital Loan Transactions tab has a reversed "DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT" transaction with date "05 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | 28.70 | | + | LIABILITY | 240005 | Deferred Interest Revenue | | 28.70 | + | LIABILITY | 240005 | Deferred Interest Revenue | 28.70 | | + | INCOME | 404000 | Interest Income | | 28.70 | + + @TestRailId:C83068 + Scenario: Multiple backdated discount fee adjustments are allowed; undo of the last one restores its share of the discount + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + And Admin adds Discount fee adjustment with "400" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 600.0 | 9600.0 | + When Admin sets the business date to "06 January 2026" + And Admin adds Discount fee adjustment with "600" amount on transaction date "03 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 0.0 | 9000.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 400.0 | 400.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Adjustment | 600.0 | 600.0 | 0.0 | 0.0 | false | + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | + | 600.0 | 9600.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 400.0 | 400.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Adjustment | 600.0 | 600.0 | 0.0 | 0.0 | true | + + @TestRailId:C83069 + Scenario: Undo of a deeply-backdated adjustment reverses its amortization adjustment and preserves pre-existing COB income + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + And Customer makes repayment on "03 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "04 January 2026" + And Customer makes repayment on "04 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "05 January 2026" + When Admin runs inline COB job for Working Capital Loan + And Admin adds Discount fee adjustment with "1000" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | + | 0.0 | 9000.0 | 150.0 | + When Admin sets the business date to "06 January 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin sets the business date to "07 January 2026" + When Admin runs inline COB job for Working Capital Loan + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | + | 1000.0 | 10000.0 | 150.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | true | + | 03 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Discount Fee Amortization | 28.70 | | | | false | + | 05 January 2026 | Discount Fee Amortization Adjustment | 28.70 | | | | true | + + @TestRailId:C83071 + Scenario: Discount can be re-adjusted after an undo frees up the remaining discount + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + And Admin adds Discount fee adjustment with "1000" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 0.0 | 9000.0 | + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | + | 1000.0 | 10000.0 | + When Admin sets the business date to "06 January 2026" + And Admin adds Discount fee adjustment with "600" amount on transaction date "03 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | + | 400.0 | 9400.0 | 0.0 | 0.0 | 400.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | true | + | 03 January 2026 | Discount Fee Adjustment | 600.0 | 600.0 | 0.0 | 0.0 | false | + + @TestRailId:C83072 + Scenario: Undo of an already-reversed discount fee adjustment is rejected + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + And Admin adds Discount fee adjustment with "1000" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + When Admin undo the last Discount fee adjustment on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | + | 1000.0 | 10000.0 | + Then Undo the last Discount fee adjustment on Working Capital loan account failed due to already reversed transaction with status code 400 + + @TestRailId:C83073 + Scenario: Backdated discount adjustment dated exactly on the discount fee date is accepted + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + And Admin adds Discount fee adjustment with "1000" amount on transaction date "01 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 0.0 | 9000.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee Adjustment | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + + @TestRailId:C83074 + Scenario: Undo of a non-last discount fee adjustment reverses only that adjustment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + And Admin adds Discount fee adjustment with "400" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + When Admin sets the business date to "06 January 2026" + And Admin adds Discount fee adjustment with "600" amount on transaction date "03 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | + | 0.0 | 9000.0 | + When Admin undo the Discount fee adjustment with "400" amount on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | + | 400.0 | 9400.0 | 0.0 | 0.0 | 400.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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 400.0 | 400.0 | 0.0 | 0.0 | true | + | 03 January 2026 | Discount Fee Adjustment | 600.0 | 600.0 | 0.0 | 0.0 | false | + + @TestRailId:C83075 + Scenario: Undo referencing a non-adjustment transaction is rejected as invalid type + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Admin adds Discount fee with "12" amount on Working Capital loan account for last disbursement + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "05 January 2026" + Then Undo discount fee adjustment referencing the discount fee transaction on Working Capital loan account failed due to invalid transaction type with status code 400 + + @TestRailId:C83076 + Scenario: Undo of a partial backdated adjustment posts a new amortization as of current date for income recognized while adjusted + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ADVANCED_ACCOUNTING | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Admin successfully add discount with "1000" amount on Working Capital loan account + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "02 January 2026" + And Admin adds Discount fee adjustment with "500" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + And Working capital loan account has the correct data: + | discount | principal | realizedIncome | unrealizedIncome | + | 500.0 | 9500.0 | 0.0 | 500.0 | + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 50 transaction amount on Working Capital loan + When Admin sets the business date to "06 January 2026" + When Admin runs inline COB job for Working Capital Loan + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | + | 500.0 | 9500.0 | 50.0 | 5.14 | 494.86 | + When Admin sets the business date to "07 January 2026" + And Admin undo the Discount fee adjustment with "500" amount on Working Capital loan account + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | + | 1000.0 | 10000.0 | 50.0 | 5.14 | 994.86 | + When Admin sets the business date to "08 January 2026" + When Admin runs inline COB job for Working Capital Loan + And Working capital loan account has the correct data: + | discount | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | + | 1000.0 | 10000.0 | 50.0 | 9.61 | 990.39 | + 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 | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Adjustment | 500.0 | 500.0 | 0.0 | 0.0 | true | + | 05 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 05 January 2026 | Discount Fee Amortization | 5.14 | | | | false | + | 06 January 2026 | Discount Fee Amortization | 4.47 | | | | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "06 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | | 4.47 | + | LIABILITY | 240005 | Deferred Interest Revenue | 4.47 | | + + @TestRailId:C83077 + Scenario: Undo of a discount fee adjustment is rejected when the loan is not active + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_ACCOUNTING_CASH_BASED | 01 January 2026 | 01 January 2026 | 100 | 100 | 18 | 0 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Admin adds Discount fee with "12" amount on Working Capital loan account for last disbursement + And Admin loads discount fee transaction from Working Capital loan for adjustment + When Admin sets the business date to "02 January 2026" + And Admin adds Discount fee adjustment with "5" amount on transaction date "02 January 2026" on Working Capital loan account for last discount + And Customer makes repayment on "02 January 2026" with 112.0 transaction amount on Working Capital loan + When Admin sets the business date to "03 January 2026" + When Admin runs inline COB job for Working Capital Loan + Then Working Capital loan status will be "OVERPAID" + Then Undo the last Discount fee adjustment on Working Capital loan account failed due to non active loan with status code 400 + + @TestRailId:C83079 + Scenario: Undo of a discount fee adjustment with a non-existent transaction id is rejected + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Undo discount fee adjustment with a non-existent transaction id on Working Capital loan account failed as not found with status code 400 diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java index 17a9636680d..4f5e5e11084 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/cob/workingcapitalloan/businessstep/DiscountFeeAmortizationBusinessStep.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.cob.workingcapitalloan.businessstep; +import java.math.BigDecimal; import java.time.LocalDate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,18 +37,25 @@ public class DiscountFeeAmortizationBusinessStep extends WorkingCapitalLoanCOBBu @Override public WorkingCapitalLoan execute(final WorkingCapitalLoan input) { - final boolean discountPresent = input.getLoanProductRelatedDetails() != null - && MathUtil.isGreaterThanZero(input.getLoanProductRelatedDetails().getDiscount()); - final boolean adjustmentNeeded = input.getBalance() != null - && MathUtil.isGreaterThanZero(input.getBalance().getRealizedIncomeFromDiscountFee()); - - if (discountPresent || adjustmentNeeded) { - final LocalDate businessDate = DateUtils.getBusinessLocalDate(); - discountFeeAmortizationService.processDiscountFeeAmortization(input, businessDate); + if (input.getLoanProductRelatedDetails() == null) { + log.debug("Skipping discount fee amortization for WC loan {} - no loan product details", input.getId()); return input; } + // Run when there is still a discount to amortize, OR when income was previously recognized and now needs to be + // reconciled down (e.g. a full discount adjustment reduced the discount to zero). Otherwise there is nothing to + // do. + final BigDecimal discount = input.getLoanProductRelatedDetails().getDiscount(); + final BigDecimal recognizedIncome = input.getBalance() == null ? null : input.getBalance().getRealizedIncomeFromDiscountFee(); + if (!MathUtil.isGreaterThanZero(discount) && !MathUtil.isGreaterThanZero(recognizedIncome)) { + log.debug("Skipping discount fee amortization for WC loan {} - no discount fee and no recognized income to reconcile", + input.getId()); + return input; + } + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + + discountFeeAmortizationService.processDiscountFeeAmortization(input, businessDate); - log.debug("Skipping discount fee amortization for WC loan {} - no discount fee", input.getId()); return input; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java index d57da4ff55d..7e6096d7de0 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java @@ -51,6 +51,7 @@ private WorkingCapitalLoanConstants() { public static final String CREDIT_BALANCE_REFUND_COMMAND = "creditBalanceRefund"; public static final String DISCOUNT_FEE_LOAN_COMMAND = "discountFee"; public static final String DISCOUNT_FEE_ADJUSTMENT_LOAN_COMMAND = "discountFeeAdjustment"; + public static final String UNDO_LOAN_COMMAND = "undo"; // Approval / Rejection / Undo-approval parameters public static final String RESOURCE_NAME = WCL_RESOURCE_NAME; @@ -80,6 +81,9 @@ private WorkingCapitalLoanConstants() { public static final String bankNumberParamName = "bankNumber"; public static final String transactionDateParamName = "transactionDate"; public static final String transactionTypeParamName = "transactionType"; + public static final String transactionIdParamName = "transactionId"; + public static final String loanStatusParamName = "loanStatus"; + public static final String loanProductRelatedDetailsParamName = "loanProductRelatedDetails"; // Transaction parameters public static final String relatedResourceIdParamName = "relatedResourceId"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java index 35d66d91904..9b999475936 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java @@ -234,4 +234,53 @@ private CommandProcessingResult executeTransaction(final Long loanId, final Stri } return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } + + @POST + @Path("{loanId}/transactions/{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandById", summary = "Execute a command on an existing Working Capital Loan transaction", description = "Supported command query parameter: undo") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsRequest.class))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsResponse.class))) }) + public CommandProcessingResult executeLoanTransactionCommandById( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @PathParam("transactionId") @Parameter(description = "transactionId", required = true) final Long transactionId, + @QueryParam("command") @Parameter(description = "command", required = true) final String commandParam, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return executeTransactionCommand(loanId, null, transactionId, commandParam, apiRequestBodyAsJson); + } + + @POST + @Path("external-id/{loanExternalId}/transactions/{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByExternalId", summary = "Execute a command on an existing Working Capital Loan transaction by loan external id", description = "Supported command query parameter: undo") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsRequest.class))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.PostWorkingCapitalLoanTransactionsResponse.class))) }) + public CommandProcessingResult executeLoanTransactionCommandByExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @PathParam("transactionId") @Parameter(description = "transactionId", required = true) final Long transactionId, + @QueryParam("command") @Parameter(description = "command", required = true) final String commandParam, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return executeTransactionCommand(null, loanExternalId, transactionId, commandParam, apiRequestBodyAsJson); + } + + private CommandProcessingResult executeTransactionCommand(final Long loanId, final String loanExternalIdStr, final Long transactionId, + final String commandParam, final String apiRequestBodyAsJson) { + final Long resolvedLoanId = loanId != null ? loanId + : loanReadPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr)); + if (resolvedLoanId == null) { + throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalIdStr)); + } + final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson); + final CommandWrapper commandRequest; + if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.UNDO_LOAN_COMMAND)) { + commandRequest = builder.undoWorkingCapitalLoanTransaction(resolvedLoanId, transactionId).build(); + } else { + throw new UnrecognizedQueryParamException("command", commandParam); + } + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoWorkingCapitalLoanTransactionCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoWorkingCapitalLoanTransactionCommandHandler.java new file mode 100644 index 00000000000..a228fd466a9 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoWorkingCapitalLoanTransactionCommandHandler.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 static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_UNDO; +import static org.apache.fineract.commands.domain.CommandWrapperConstants.ENTITY_WORKINGCAPITALLOAN; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +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.WorkingCapitalLoanWritePlatformService; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@CommandType(entity = ENTITY_WORKINGCAPITALLOAN, action = ACTION_UNDO) +public class UndoWorkingCapitalLoanTransactionCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanWritePlatformService writePlatformService; + + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return writePlatformService.undoTransaction(command.getLoanId(), command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java index 59e4c0cb9a3..2586eb60b87 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -21,10 +21,13 @@ import java.util.List; import java.util.Optional; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface WorkingCapitalLoanTransactionRepository extends JpaRepository { @@ -34,7 +37,16 @@ public interface WorkingCapitalLoanTransactionRepository extends JpaRepository findByIdAndWcLoan_Id(Long id, Long wcLoanId); + @Query(""" + select t from WorkingCapitalLoanTransaction t + where t.wcLoan.id = :wcLoanId and t.transactionType = :transactionType and t.reversed = false + order by t.id desc + """) + List findActiveByTypeOrderByIdDesc(@Param("wcLoanId") Long wcLoanId, + @Param("transactionType") LoanTransactionType transactionType); + Optional findByWcLoan_IdAndExternalId(Long wcLoanId, ExternalId externalId); boolean existsByExternalId(ExternalId externalId); + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index d60d51c7c5d..3a0956369fa 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -207,10 +207,6 @@ public void validateDiscountAdjustmentTransaction(final WorkingCapitalLoan loan, baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName) .failWithCode("cannot.be.before.discount.fee.date"); } - if (DateUtils.isBefore(effectiveTransactionDate, DateUtils.getBusinessLocalDate())) { - baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionDateParamName) - .failWithCode("backdated.not.allowed"); - } } final Integer classificationId = this.fromApiJsonHelper @@ -244,8 +240,31 @@ public void validateDiscountAdjustmentTransaction(final WorkingCapitalLoan loan, } validatePaymentDetails(baseDataValidator, element); if (loan.getLoanStatus() == null || !loan.getLoanStatus().isActive()) { - baseDataValidator.reset().parameter("loanStatus").failWithCode("adjustment.only.allowed.for.active.loan"); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("adjustment.only.allowed.for.active.loan"); + } + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateUndoDiscountAdjustmentTransaction(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction adjustmentTransaction) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + + if (adjustmentTransaction.isReversed()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionIdParamName) + .failWithCode("discount.adjustment.already.reversed"); + } + if (loan.getLoanProductRelatedDetails() == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanProductRelatedDetailsParamName) + .failWithCode("discount.not.available"); + } + if (loan.getLoanStatus() == null || !loan.getLoanStatus().isActive()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("undo.discount.adjustment.only.allowed.for.active.loan"); } + throwExceptionIfValidationWarningsExist(dataValidationErrors); } @@ -669,13 +688,14 @@ public void validateRepayment(final String json, final WorkingCapitalLoan loan, if (LoanTransactionType.REPAYMENT.equals(goodwillCredit)) { if (loan.getLoanStatus() != LoanStatus.ACTIVE && loan.getLoanStatus() != LoanStatus.OVERPAID) { throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", - "Repayment is allowed only for active/overpaid loans", "loanStatus"); + "Repayment is allowed only for active/overpaid loans", WorkingCapitalLoanConstants.loanStatusParamName); } } else if (LoanTransactionType.GOODWILL_CREDIT.equals(goodwillCredit)) { if (!LoanStatus.ACTIVE.equals(loan.getLoanStatus()) && !LoanStatus.CLOSED_OBLIGATIONS_MET.equals(loan.getLoanStatus()) && !LoanStatus.OVERPAID.equals(loan.getLoanStatus())) { throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", - "Goodwill Credit is allowed only for active/closed obligations met/overpaid loans", "loanStatus"); + "Goodwill Credit is allowed only for active/closed obligations met/overpaid loans", + WorkingCapitalLoanConstants.loanStatusParamName); } } } @@ -765,7 +785,8 @@ public void validateUpdatePeriodPaymentRate(final String json, final WorkingCapi final JsonElement element = this.fromApiJsonHelper.parse(json); if (loan.getLoanStatus() != LoanStatus.ACTIVE) { - baseDataValidator.reset().parameter("loanStatus").failWithCode("rate.change.not.allowed.for.non.active.loan"); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) + .failWithCode("rate.change.not.allowed.for.non.active.loan"); } final BigDecimal periodPaymentRate = this.fromApiJsonHelper diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java index 65db465d94c..37d608891dd 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java @@ -43,5 +43,5 @@ public interface WorkingCapitalLoanAmortizationScheduleWriteService { * After a discount fee adjustment: regenerates the projected schedule with the new loan-level discount (as on * disbursement generation) and re-applies recorded actual repayments only. */ - void applyDiscountFeeAdjustment(WorkingCapitalLoan loan, LocalDate adjustmentTransactionDate); + void applyDiscountFeeAdjustment(WorkingCapitalLoan loan); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index 3c0c9493d0c..a05f7c82911 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -206,9 +206,8 @@ public void regenerateAmortizationScheduleOnRateChange(final WorkingCapitalLoan } @Override - public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan, final LocalDate adjustmentTransactionDate) { + public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { Validate.notNull(loan, "loan must not be null"); - Validate.notNull(adjustmentTransactionDate, "adjustmentTransactionDate must not be null"); final MathContext mc = MoneyHelper.getMathContext(); final CurrencyData currency = WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java index f60a2ec908b..d8b99dbffa8 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeAmortizationServiceImpl.java @@ -26,10 +26,13 @@ import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelation; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.springframework.stereotype.Service; @@ -75,16 +78,17 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final final WorkingCapitalLoanTransaction amortizationTxn = WorkingCapitalLoanTransaction.discountFeeAmortization(loan, amortizationAmount, transactionDate, externalIdFactory.create()); transactionRepository.saveAndFlush(amortizationTxn); - accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, false); + accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, isChargedOff); } else { final BigDecimal adjustmentAmount = amortizationAmount.negate(); final WorkingCapitalLoanTransaction adjustmentTxn = WorkingCapitalLoanTransaction.discountFeeAmortizationAdjustment(loan, adjustmentAmount, transactionDate, externalIdFactory.create()); + linkToTriggeringDiscountAdjustment(loan, adjustmentTxn); transactionRepository.saveAndFlush(adjustmentTxn); accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, isChargedOff); } - loan.getBalance().setRealizedIncomeFromDiscountFee(loan.getBalance().getRealizedIncomeFromDiscountFee().add(amortizationAmount)); + loan.getBalance().setRealizedIncomeFromDiscountFee(alreadyPosted.add(amortizationAmount)); log.debug("Posted discount fee amortization of {} for WC loan [{}]", amortizationAmount, loan.getId()); } @@ -94,4 +98,12 @@ private BigDecimal calculateScheduleAmortization(final WorkingCapitalLoan loan) return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan)) .map(ProjectedAmortizationScheduleModel::totalActualAmortization).orElse(BigDecimal.ZERO); } + + private void linkToTriggeringDiscountAdjustment(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction amortizationAdjustment) { + transactionRepository.findActiveByTypeOrderByIdDesc(loan.getId(), LoanTransactionType.DISCOUNT_FEE_ADJUSTMENT).stream().findFirst() + .ifPresent(discountAdjustment -> amortizationAdjustment.getLoanTransactionRelations() + .add(new WorkingCapitalLoanTransactionRelation(amortizationAdjustment, discountAdjustment, + LoanTransactionRelationTypeEnum.RELATED))); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java index 5619b9aaf26..50c141984c5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java @@ -39,6 +39,8 @@ public interface WorkingCapitalLoanWritePlatformService { CommandProcessingResult makeDiscountFeeAdjustment(Long loanId, JsonCommand command); + CommandProcessingResult undoTransaction(Long loanId, Long transactionId, JsonCommand command); + CommandProcessingResult creditBalanceRefund(Long loanId, JsonCommand command); CommandProcessingResult makeGoodwillCredit(Long loanId, JsonCommand command); 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 24fe98b58e4..3e639a2c0d8 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 @@ -105,7 +105,6 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanTransactionRelationRepository relationRepository; private final WorkingCapitalLoanPeriodPaymentRateChangeRepository rateChangeRepository; private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService; - private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -428,9 +427,7 @@ private WorkingCapitalLoanTransaction createAndPersistDiscountFeeTransaction(fin .forDisbursementDiscount(discountTransaction, amount); allocationRepository.saveAndFlush(allocation); - // Regenerate with the new discount while preserving repayments already recorded on the disbursement schedule - // (e.g. a same-day repayment that shifted the grid); a plain regeneration would drop them. - amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan, disbursementTransaction.getTransactionDate()); + amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); businessEventNotifierService .notifyPostBusinessEvent(new WorkingCapitalLoanDiscountFeeTransactionBusinessEvent(discountTransaction)); @@ -519,7 +516,8 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina "Related discount transaction ID is required for discount fee adjustment", WorkingCapitalLoanConstants.relatedResourceIdParamName); } - final WorkingCapitalLoanTransaction relatedDiscountTransaction = transactionRepository.findById(relatedDiscountTransactionId) + final WorkingCapitalLoanTransaction relatedDiscountTransaction = transactionRepository + .findByIdAndWcLoan_Id(relatedDiscountTransactionId, loanId) .orElseThrow(() -> new PlatformApiDataValidationException("validation.msg.wc.loan.discount.transaction.not.found", "Discount transaction not found", WorkingCapitalLoanConstants.relatedResourceIdParamName)); if (!relatedDiscountTransaction.getTypeOf().isDiscountFee() || relatedDiscountTransaction.isReversed()) { @@ -565,7 +563,7 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina loan.getLoanProductRelatedDetails() .setDiscount((currentDiscount != null ? currentDiscount : BigDecimal.ZERO).subtract(amount).max(BigDecimal.ZERO)); - amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan, transactionDate); + amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); updateBalanceForDiscountChange(loan, amount, true); handleStateChanges(loan, transactionDate); @@ -592,6 +590,47 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina .withClientId(loan.getClientId()).withLoanId(loanId).with(changes).build(); } + @Override + public CommandProcessingResult undoTransaction(final Long loanId, final Long transactionId, final JsonCommand command) { + final WorkingCapitalLoan loan = loanRepository.findById(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + final WorkingCapitalLoanTransaction transaction = transactionRepository.findByIdAndWcLoan_Id(transactionId, loanId) + .orElseThrow(() -> new PlatformApiDataValidationException("validation.msg.wc.loan.transaction.not.found", + "Working capital loan transaction not found", WorkingCapitalLoanConstants.transactionIdParamName)); + return switch (transaction.getTypeOf()) { + case DISCOUNT_FEE_ADJUSTMENT -> undoDiscountFeeAdjustment(loan, transaction, command); + default -> throw new PlatformApiDataValidationException("validation.msg.wc.loan.transaction.undo.not.supported", + "Undo is not supported for transaction type " + transaction.getTypeOf(), + WorkingCapitalLoanConstants.transactionTypeParamName); + }; + } + + private CommandProcessingResult undoDiscountFeeAdjustment(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction adjustmentTransaction, final JsonCommand command) { + validator.validateUndoDiscountAdjustmentTransaction(loan, adjustmentTransaction); + + reverseTransaction(adjustmentTransaction); + reverseDiscountFeeAmortizationAdjustments(loan, adjustmentTransaction); + + final BigDecimal currentDiscount = loan.getLoanProductRelatedDetails().getDiscount(); + loan.getLoanProductRelatedDetails().setDiscount( + (currentDiscount != null ? currentDiscount : BigDecimal.ZERO).add(adjustmentTransaction.getTransactionAmount())); + + amortizationScheduleWriteService.applyDiscountFeeAdjustment(loan); + updateBalanceForDiscountChange(loan, adjustmentTransaction.getTransactionAmount().negate(), true); + loanRepository.saveAndFlush(loan); + + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + createNote(noteText, loan); + + final Map changes = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(adjustmentTransaction.getId()) + .withEntityExternalId(adjustmentTransaction.getExternalId()).withOfficeId(loan.getOfficeId()) + .withClientId(loan.getClientId()).withLoanId(loan.getId()).with(changes).build(); + } + @Override public CommandProcessingResult makeRepayment(final Long loanId, final JsonCommand command) { return makeRepaymentLikeTransaction(loanId, command, LoanTransactionType.REPAYMENT); @@ -982,4 +1021,17 @@ private void createNote(final String noteText, final WorkingCapitalLoan loan) { } } + private void reverseDiscountFeeAmortizationAdjustments(final WorkingCapitalLoan loan, + final WorkingCapitalLoanTransaction discountAdjustment) { + final WorkingCapitalLoanBalance balance = loan.getBalance(); + relationRepository.findAllByToTransactionAndFromTransactionReversedAndFromTransactionTransactionType(discountAdjustment, false, + LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT).forEach(relation -> { + final WorkingCapitalLoanTransaction txn = relation.getFromTransaction(); + reverseTransaction(txn); + accountingProcessor.postReversalJournalEntries(loan, txn); + balance.setRealizedIncomeFromDiscountFee( + MathUtil.nullToZero(balance.getRealizedIncomeFromDiscountFee()).add(txn.getTransactionAmount())); + }); + } + }