From 2291bac3745dca8ced06f3a31d4722cd3d3b9cae Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Fri, 18 Sep 2026 17:28:53 +0300 Subject: [PATCH] FINERACT-2455: WC - Support discount fee and discount fee adjustment by transaction external id --- .../service/CommandWrapperBuilder.java | 18 ++++ .../working-capital-discount-fee-txn.adoc | 18 ++-- .../factory/WorkingCapitalRequestFactory.java | 7 ++ .../loan/WorkingCapitalBatchApiStepDef.java | 29 +++++++ .../WorkingCapitalLoanAccountStepDef.java | 37 +++++++++ .../features/WorkingCapitalBatchApi.feature | 4 + .../features/WorkingCapitalDiscount.feature | 27 ++++++ ...ingCapitalLoanTransactionsApiResource.java | 12 ++- ...talLoanTransactionsApiResourceSwagger.java | 20 ++++- ...anDiscountFeeAdjustmentCommandHandler.java | 7 +- ...gCapitalLoanDiscountFeeCommandHandler.java | 7 +- .../WorkingCapitalLoanDataValidator.java | 18 ++++ ...orkingCapitalLoanWritePlatformService.java | 4 + ...ngCapitalLoanWritePlatformServiceImpl.java | 33 +++++++- ...scountFeeRelatedTransactionInPathTest.java | 83 +++++++++++++++++++ 15 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeRelatedTransactionInPathTest.java 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..c57d8b9c475 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 @@ -864,6 +864,24 @@ public CommandWrapperBuilder discountFeeAdjustmentWorkingCapitalLoanTransaction( return this; } + public CommandWrapperBuilder discountFeeWorkingCapitalLoanTransaction(final Long loanId, final Long transactionId) { + this.actionName = ACTION_DISCOUNTFEE; + this.entityName = ENTITY_WORKINGCAPITALLOAN; + this.entityId = transactionId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/transactions/" + transactionId + "?command=discountFee"; + return this; + } + + public CommandWrapperBuilder discountFeeAdjustmentWorkingCapitalLoanTransaction(final Long loanId, final Long transactionId) { + this.actionName = ACTION_DISCOUNTFEEADJUSTMENT; + this.entityName = ENTITY_WORKINGCAPITALLOAN; + this.entityId = transactionId; + this.loanId = loanId; + this.href = "/working-capital-loans/" + loanId + "/transactions/" + transactionId + "?command=discountFeeAdjustment"; + return this; + } + public CommandWrapperBuilder undoWorkingCapitalLoanTransaction(final Long loanId, final Long transactionId) { this.actionName = ACTION_UNDO; this.entityName = ENTITY_WORKINGCAPITALLOANTRANSACTION; diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-discount-fee-txn.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-discount-fee-txn.adoc index f8e5b76096e..8c0895f03a2 100644 --- a/fineract-doc/src/docs/en/chapters/features/working-capital-discount-fee-txn.adoc +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-discount-fee-txn.adoc @@ -35,7 +35,7 @@ The scope of this document includes: A `DISCOUNT_FEE` transaction can be created in two ways: * Automatically during disbursement when the resolved discount amount is greater than zero. -* Explicitly after disbursement using `POST /transactions?command=discountFee`, if no Discount Fee was created for the related disbursement. +* Explicitly after disbursement using `POST /transactions?command=discountFee`, or by naming the disbursement in the path (`/transactions/{transactionId}` or `/transactions/external-id/{transactionExternalId}`), if no Discount Fee was created for the related disbursement. Each Discount Fee transaction is linked to its disbursement transaction through `m_wc_loan_transaction_relation` with relation type `RELATED`. @@ -63,7 +63,7 @@ A `DISCOUNT_FEE_ADJUSTMENT` transaction reduces the loan-level discount that was === Applicability * Loan must be ACTIVE. -* `relatedResourceId` must reference an active, non-reversed `DISCOUNT_FEE` transaction. +* The related Discount Fee transaction (`relatedResourceId`, or the transaction in the path) must be an active, non-reversed `DISCOUNT_FEE` transaction. * Multiple adjustments are allowed against the same Discount Fee transaction until the remaining adjustable amount reaches zero. === Transaction Behavior @@ -101,7 +101,7 @@ On Discount Fee Adjustment: | Adjustment is allowed only for ACTIVE loans | Parent transaction -| `relatedResourceId` must reference a non-reversed Discount Fee transaction +| `relatedResourceId`, or the transaction in the path, must reference a non-reversed Discount Fee transaction |=== === Allocation @@ -134,6 +134,10 @@ When disbursement is reversed: ---- POST /v1/working-capital-loans/{loanId}/transactions?command=discountFee POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=discountFee +POST /v1/working-capital-loans/{loanId}/transactions/{transactionId}?command=discountFee +POST /v1/working-capital-loans/{loanId}/transactions/external-id/{transactionExternalId}?command=discountFee +POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions/{transactionId}?command=discountFee +POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions/external-id/{transactionExternalId}?command=discountFee ---- [source,json] @@ -157,7 +161,7 @@ POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command ---- * `transactionAmount` is optional. If omitted, the service defaults to the loan's current discount. -* `relatedResourceId` is mandatory and must be the related disbursement transaction ID. +* On `/transactions?command=discountFee`, `relatedResourceId` is mandatory and must be the related disbursement transaction ID. On `/transactions/{transactionId}` and `/transactions/external-id/{transactionExternalId}` the path names the disbursement and `relatedResourceId` is not accepted. An unknown transaction external id, or one that belongs to another loan, returns 404. * `classificationId` is optional and must reference `working_capital_loan_discount_fee_classification` when provided. === Create Discount Fee Adjustment @@ -166,6 +170,10 @@ POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command ---- POST /v1/working-capital-loans/{loanId}/transactions?command=discountFeeAdjustment POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=discountFeeAdjustment +POST /v1/working-capital-loans/{loanId}/transactions/{transactionId}?command=discountFeeAdjustment +POST /v1/working-capital-loans/{loanId}/transactions/external-id/{transactionExternalId}?command=discountFeeAdjustment +POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions/{transactionId}?command=discountFeeAdjustment +POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions/external-id/{transactionExternalId}?command=discountFeeAdjustment ---- [source,json] @@ -182,7 +190,7 @@ POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command } ---- -* `relatedResourceId` is mandatory and must be the Discount Fee transaction ID. +* On `/transactions?command=discountFeeAdjustment`, `relatedResourceId` is mandatory and must be the Discount Fee transaction ID. On `/transactions/{transactionId}` and `/transactions/external-id/{transactionExternalId}` the path names the Discount Fee transaction and `relatedResourceId` is not accepted. An unknown transaction external id, or one that belongs to another loan, returns 404. * `transactionAmount` is mandatory and capped by the remaining adjustable discount. * `classificationId` is optional and must reference `working_capital_loan_discount_fee_classification` when provided. diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java index cedba0441c7..a1ce10dc24f 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java @@ -37,6 +37,7 @@ import org.apache.fineract.client.models.DelinquencyBucketRequest; import org.apache.fineract.client.models.DelinquencyBucketResponse; import org.apache.fineract.client.models.DelinquencyRangeResponse; +import org.apache.fineract.client.models.ExecuteWorkingCapitalLoanTransactionCommandRequest; import org.apache.fineract.client.models.MinimumPaymentPeriodAndRule; import org.apache.fineract.client.models.PaymentAllocationOrder; import org.apache.fineract.client.models.PostAllowAttributeOverrides; @@ -325,6 +326,12 @@ public PostWorkingCapitalLoanTransactionsRequest defaultWorkingCapitalLoanRepaym .locale(LOCALE_EN); } + public ExecuteWorkingCapitalLoanTransactionCommandRequest defaultWorkingCapitalLoanTransactionCommandRequest() { + return new ExecuteWorkingCapitalLoanTransactionCommandRequest() // + .dateFormat(DATE_FORMAT) // + .locale(LOCALE_EN); + } + private Long getWCBreachIdByName(String breachName) { try { List breaches = fineractClient.workingCapitalBreaches().retrieveAllWorkingCapitalBreaches(Map.of()); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBatchApiStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBatchApiStepDef.java index 211a7f4024a..c82c605ac0e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBatchApiStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalBatchApiStepDef.java @@ -47,6 +47,7 @@ import org.apache.fineract.client.models.Header; import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoanTransactionsResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; @@ -79,6 +80,7 @@ public class WorkingCapitalBatchApiStepDef extends AbstractStepDef { private static final String COMMAND_REJECT = "?command=reject"; private static final String COMMAND_DISBURSE = "?command=disburse"; private static final String COMMAND_DISCOUNT = "?command=discountFee"; + private static final String COMMAND_DISCOUNT_ADJUSTMENT = "?command=discountFeeAdjustment"; private static final String WCL_TRANSACTIONS_PATH = "/transactions"; private final FineractFeignClient fineractFeignClient; @@ -231,6 +233,33 @@ public void batchApiAddDiscountFee(String amount) throws IOException { testContext().set(TestContextKey.BATCH_API_CALL_RESPONSE, responses); } + @When("Batch API adds discount fee with {string} amount referencing the disbursement external-id in relatedResourceId on the working capital loan") + public void batchApiAddDiscountFeeWithDisbursementExternalIdInRelatedResourceId(final String amount) throws IOException { + final PostWorkingCapitalLoansLoanIdResponse disburseResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE); + batchApiDiscountCommandWithStringRelatedResourceId(COMMAND_DISCOUNT, amount, disburseResponse.getResourceExternalId()); + } + + @When("Batch API adds discount fee adjustment with {string} amount referencing the discount fee external-id in relatedResourceId on the working capital loan") + public void batchApiAddDiscountFeeAdjustmentWithDiscountFeeExternalIdInRelatedResourceId(final String amount) throws IOException { + final List discountFeeResponses = testContext().get(TestContextKey.BATCH_API_CALL_RESPONSE); + final String discountFeeExternalId = fromJson(discountFeeResponses.getFirst().getBody(), + PostWorkingCapitalLoanTransactionsResponse.class).getResourceExternalId(); + batchApiDiscountCommandWithStringRelatedResourceId(COMMAND_DISCOUNT_ADJUSTMENT, amount, discountFeeExternalId); + } + + private void batchApiDiscountCommandWithStringRelatedResourceId(final String command, final String amount, + final String relatedResourceId) throws IOException { + final PostWorkingCapitalLoanTransactionsRequest request = new PostWorkingCapitalLoanTransactionsRequest() + .transactionDate("01 January 2026").transactionAmount(new BigDecimal(amount)) + .locale(WorkingCapitalLoanRequestFactory.DEFAULT_LOCALE).dateFormat(WorkingCapitalLoanRequestFactory.DATE_FORMAT); + final String serialized = GSON.toJson(request); + final String body = serialized.substring(0, serialized.lastIndexOf('}')) + ",\"relatedResourceId\":\"" + relatedResourceId + "\"}"; + final String url = resolveLoanUrlForGet() + WCL_TRANSACTIONS_PATH + command; + final BatchRequest batchRequest = buildBatchRequest(1L, null, url, BATCH_API_METHOD_POST, body); + final List responses = handleBatchRequests(List.of(batchRequest), false); + testContext().set(TestContextKey.BATCH_API_CALL_RESPONSE, responses); + } + @When("Batch API fetches working capital loan details by loan ID") public void batchApiGetWCLoanById() throws IOException { final BatchRequest batchRequest = buildBatchRequest(1L, null, resolveLoanUrlForGet(), BATCH_API_METHOD_GET, BODY_GET_REQUEST); 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 ffe8e9400cc..70906375ab2 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 @@ -2077,6 +2077,32 @@ public void addDiscountFeeWCLoanDisbursementWithRandomExternalId(final String di testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_DISCOUNT_FEE_RESPONSE, response); } + @When("Admin adds Discount fee with {string} amount, a random externalId, {string} classification and {string} payment type by loan and disbursement external-ids on Working Capital loan account") + public void addDiscountFeeByLoanAndDisbursementExternalIds(final String discountAmount, final String classificationCodeValueName, + final String paymentTypeName) { + final PostWorkingCapitalLoansLoanIdResponse lastDisbursementResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE); + Assertions.assertNotNull(lastDisbursementResponse); + final PostWorkingCapitalLoansLoanIdRequest lastDisbursementRequest = testContext().get(TestContextKey.LOAN_DISBURSE_REQUEST); + final String randomExternalId = Utils.randomStringGenerator("TestDiscountFeeExtId_", 10); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_DISCOUNT_FEE_EXTERNAL_ID_USER_GENERATED, randomExternalId); + + final String loanExternalId = retrieveLoanExternalId(getCreatedLoanId()); + final Long classificationId = getClassificationCodeValueId(CodeNames.WORKING_CAPITAL_DISCOUNT_FEE_CLASSIFICATION.getValue(), + classificationCodeValueName); + final long paymentTypeId = paymentTypeResolver.resolve(DefaultPaymentType.valueOf(paymentTypeName)); + final ExecuteWorkingCapitalLoanTransactionCommandRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanTransactionCommandRequest().transactionDate(lastDisbursementRequest.getActualDisbursementDate()) + .transactionAmount(new BigDecimal(discountAmount)).note("Discount applied").externalId(randomExternalId) + .classificationId(classificationId) + .paymentDetails(new PostWorkingCapitalLoanTransactionsPaymentDetailRequest().paymentTypeId(paymentTypeId)); + + ok(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionCommandByLoanExternalIdTransactionExternalId(loanExternalId, + lastDisbursementResponse.getResourceExternalId(), "discountFee", request)); + rememberLastWorkingCapitalTransaction(TransactionType.DISCOUNT_FEE.getValue(), lastDisbursementRequest.getActualDisbursementDate(), + request.getTransactionAmount()); + } + @Then("Adding Discount fee with {string} amount reusing the previously shared externalId on Working Capital loan account for last disbursement results an error with the following data:") public void addDiscountFeeReusingSharedExternalIdResultsAnError(final String discountAmount, final DataTable table) { final PostWorkingCapitalLoansLoanIdResponse lastDisbursementResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE); @@ -2225,6 +2251,17 @@ public void addDiscountFeeAdjustmentWCLoan(final String adjustmentAmount) { executeDiscountFeeAdjustmentById(getCreatedLoanId(), request); } + @When("Admin adds Discount fee adjustment with {string} amount by loan and discount fee external-ids on Working Capital loan account") + public void addDiscountFeeAdjustmentByLoanAndDiscountFeeExternalIds(final String adjustmentAmount) { + final String loanExternalId = retrieveLoanExternalId(getCreatedLoanId()); + final ExecuteWorkingCapitalLoanTransactionCommandRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanTransactionCommandRequest().transactionAmount(new BigDecimal(adjustmentAmount)); + + ok(() -> fineractClient.workingCapitalLoanTransactions() + .executeWorkingCapitalLoanTransactionCommandByLoanExternalIdTransactionExternalId(loanExternalId, + latestActiveTransactionOfType(TransactionType.DISCOUNT_FEE).getExternalId(), "discountFeeAdjustment", request)); + } + @And("Admin adds Discount fee adjustment with {string} amount on transaction date {string} on Working Capital loan account for last discount") public void addDiscountFeeAdjustmentWCLoanWithTransactionDate(final String adjustmentAmount, final String transactionDate) { final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext() diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBatchApi.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBatchApi.feature index cd4082035f4..281b970f5ad 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBatchApi.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBatchApi.feature @@ -96,8 +96,12 @@ Feature: Working Capital Batch API | WCLP_DISCOUNT | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + When Batch API adds discount fee with "12" amount referencing the disbursement external-id in relatedResourceId on the working capital loan + Then Verify that WCL step 1 throws an error with error code 400 and message "relatedResourceId.not.a.number" When Batch API adds discount fee with "12" amount on the working capital loan Then Admin checks that all steps result 200OK + When Batch API adds discount fee adjustment with "5" amount referencing the discount fee external-id in relatedResourceId on the working capital loan + Then Verify that WCL step 1 throws an error with error code 400 and message "relatedResourceId.not.a.number" 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 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature index 773457b520d..079220b6212 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDiscount.feature @@ -1385,6 +1385,33 @@ Feature: Working Capital Discount Then In Working Capital Loan Transactions all transactions have non-blank external-id Then Active Discount Fee transactions contain the user-generated externalId from DISCOUNTFEE + Scenario: Discount fee and its adjustment added by loan and transaction external ids, the discount fee externalId, classification and payment type are persisted as sent + 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 Working capital loan approval was successful + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + Then Verify Working Capital loan disbursement was successful + Then Admin adds Discount fee with "12" amount, a random externalId, "working_capital_loan_discount_fee_classification_value" classification and "AUTOPAY" payment type by loan and disbursement external-ids on Working Capital loan account + 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 | + Then Active Discount Fee transactions contain the user-generated externalId from DISCOUNTFEE + And Working Capital Loan has a "DISCOUNT_FEE" transaction with date "01 January 2026" which has classification code value "working_capital_loan_discount_fee_classification_value" + And Working Capital loan transaction with type "DISCOUNT_FEE" has payment type "AUTOPAY" + When Admin adds Discount fee adjustment with "5" amount by loan and discount fee external-ids on Working Capital loan account + Then 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 | + | 01 January 2026 | Discount Fee Adjustment | 5.0 | 5.0 | 0.0 | 0.0 | false | + @TestRailId:C83041 Scenario: Discount provided during disbursement without externalId gets an auto-generated externalId When Admin sets the business date to "01 January 2026" 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 550bd85afcd..0573e8c13f9 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 @@ -265,7 +265,7 @@ private Long resolveLoanId(Long loanId, String loanExternalId) { @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId", summary = "Execute Working Capital Loan transaction command by loan id and transaction id", description = "Supported command query parameter: undo") + @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId", summary = "Execute Working Capital Loan transaction command by loan id and transaction id", description = "Supported command query parameter: undo, discountFee, discountFeeAdjustment") @Path("{loanId}/transactions/{transactionId}") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.ExecuteWorkingCapitalLoanTransactionCommandRequest.class))) @ApiResponses({ @@ -281,7 +281,7 @@ public CommandProcessingResult executeWorkingCapitalLoanTransactionCommandByLoan @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionExternalId", summary = "Execute Working Capital Loan transaction command by loan id and transaction external id", description = "Supported command query parameter: undo") + @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionExternalId", summary = "Execute Working Capital Loan transaction command by loan id and transaction external id", description = "Supported command query parameter: undo, discountFee, discountFeeAdjustment") @Path("{loanId}/transactions/external-id/{transactionExternalId}") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.ExecuteWorkingCapitalLoanTransactionCommandRequest.class))) @ApiResponses({ @@ -347,7 +347,7 @@ private WorkingCapitalLoanTransactionTemplateData getWorkingCapitalLoanTransacti @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanExternalIdTransactionId", summary = "Execute Working Capital Loan transaction command by loan external id and transaction id", description = "Supported command query parameter: undo") + @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanExternalIdTransactionId", summary = "Execute Working Capital Loan transaction command by loan external id and transaction id", description = "Supported command query parameter: undo, discountFee, discountFeeAdjustment") @Path("external-id/{loanExternalId}/transactions/{transactionId}") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.ExecuteWorkingCapitalLoanTransactionCommandRequest.class))) @ApiResponses({ @@ -363,7 +363,7 @@ public CommandProcessingResult executeWorkingCapitalLoanTransactionCommandByLoan @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanExternalIdTransactionExternalId", summary = "Execute Working Capital Loan transaction command by loan external id and transaction external id", description = "Supported command query parameter: undo") + @Operation(operationId = "executeWorkingCapitalLoanTransactionCommandByLoanExternalIdTransactionExternalId", summary = "Execute Working Capital Loan transaction command by loan external id and transaction external id", description = "Supported command query parameter: undo, discountFee, discountFeeAdjustment") @Path("external-id/{loanExternalId}/transactions/external-id/{transactionExternalId}") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.ExecuteWorkingCapitalLoanTransactionCommandRequest.class))) @ApiResponses({ @@ -386,6 +386,10 @@ private CommandProcessingResult executeWorkingCapitalLoanTransactionCommand(Long final CommandWrapper commandRequest; if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.UNDO_COMMAND)) { commandRequest = builder.undoWorkingCapitalLoanTransaction(resolvedLoanId, resolvedTransactionId).build(); + } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.DISCOUNT_FEE_LOAN_COMMAND)) { + commandRequest = builder.discountFeeWorkingCapitalLoanTransaction(resolvedLoanId, resolvedTransactionId).build(); + } else if (CommandParameterUtil.is(commandParam, WorkingCapitalLoanConstants.DISCOUNT_FEE_ADJUSTMENT_LOAN_COMMAND)) { + commandRequest = builder.discountFeeAdjustmentWorkingCapitalLoanTransaction(resolvedLoanId, resolvedTransactionId).build(); } else { throw new UnrecognizedQueryParamException("command", commandParam); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java index f0710993235..15350198435 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java @@ -201,7 +201,7 @@ private PostWorkingCapitalLoanTransactionsRequest() {} public String dateFormat; @Schema(example = "28 June 2024", description = "Transaction date") public String transactionDate; - @Schema(example = "42", description = "Disbursement transaction id for discountFee; discount fee transaction id for discountFeeAdjustment") + @Schema(example = "42", description = "Disbursement transaction id for discountFee; discount fee transaction id for discountFeeAdjustment. Not accepted on transactions/{transactionId} and transactions/external-id/{transactionExternalId}, where the path names the related transaction") public Long relatedResourceId; @Schema(example = "100.0", description = "Transaction amount. For command=recoveryPayment it may not exceed the loan's writtenOffOutstanding") public BigDecimal transactionAmount; @@ -243,8 +243,24 @@ public static final class ExecuteWorkingCapitalLoanTransactionCommandRequest { private ExecuteWorkingCapitalLoanTransactionCommandRequest() {} - @Schema(example = "loan-ext-001") + @Schema(example = "loan-ext-001", description = "Optional external id for the reversal (command=undo)") public String reversalExternalId; + @Schema(example = "en_GB") + public String locale; + @Schema(example = "dd MMMM yyyy") + public String dateFormat; + @Schema(example = "28 June 2024", description = "Transaction date (command=discountFeeAdjustment); command=discountFee uses the date of the related disbursement") + public String transactionDate; + @Schema(example = "100.0", description = "Transaction amount (command=discountFee, discountFeeAdjustment)") + public BigDecimal transactionAmount; + @Schema(example = "12", description = "Optional code value id for transaction classification (command=discountFee, discountFeeAdjustment)") + public Long classificationId; + @Schema(example = "Discount applied") + public String note; + @Schema(example = "discount-fee-ext-001", description = "Optional external id for the created transaction (command=discountFee, discountFeeAdjustment)") + public String externalId; + @Schema(description = "Payment details (command=discountFee, discountFeeAdjustment)") + public PostWorkingCapitalLoanTransactionsPaymentDetailRequest paymentDetails; } @Schema(description = "Response for working capital loan transaction command execution") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeAdjustmentCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeAdjustmentCommandHandler.java index e25f649ec34..64c3dbc81c6 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeAdjustmentCommandHandler.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeAdjustmentCommandHandler.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.handler; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; import org.apache.fineract.commands.handler.NewCommandSourceHandler; @@ -34,7 +35,9 @@ public class WorkingCapitalLoanDiscountFeeAdjustmentCommandHandler implements Ne private final WorkingCapitalLoanWritePlatformService writePlatformService; @Override - public CommandProcessingResult processCommand(JsonCommand command) { - return writePlatformService.makeDiscountFeeAdjustment(command.getResourceId(), command); + public CommandProcessingResult processCommand(final JsonCommand command) { + return Optional.ofNullable(command.getLoanId()) + .map(loanId -> writePlatformService.makeDiscountFeeAdjustment(loanId, command.entityId(), command)) + .orElseGet(() -> writePlatformService.makeDiscountFeeAdjustment(command.getResourceId(), command)); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeCommandHandler.java index a5b460bd3c6..3ce55fe03c4 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeCommandHandler.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/WorkingCapitalLoanDiscountFeeCommandHandler.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.workingcapitalloan.handler; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.annotation.CommandType; import org.apache.fineract.commands.handler.NewCommandSourceHandler; @@ -35,7 +36,9 @@ public class WorkingCapitalLoanDiscountFeeCommandHandler implements NewCommandSo private final WorkingCapitalLoanWritePlatformService writePlatformService; @Override - public CommandProcessingResult processCommand(JsonCommand command) { - return writePlatformService.makeDiscountFee(command.getResourceId(), command); + public CommandProcessingResult processCommand(final JsonCommand command) { + return Optional.ofNullable(command.getLoanId()) + .map(loanId -> writePlatformService.makeDiscountFee(loanId, command.entityId(), command)) + .orElseGet(() -> writePlatformService.makeDiscountFee(command.getResourceId(), command)); } } 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 b2bf99be33d..a2fb442837b 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 @@ -41,6 +41,7 @@ import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; @@ -152,6 +153,23 @@ public class WorkingCapitalLoanDataValidator { private static final Set REPAYMENT_LIKE_TXN_ALLOWED_LOAN_STATUSES = Set.of(LoanStatus.ACTIVE, LoanStatus.CLOSED_OBLIGATIONS_MET, LoanStatus.OVERPAID); + public void validateRelatedResourceId(final JsonElement element) { + final String relatedResourceId = fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.relatedResourceIdParamName, + element); + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.relatedResourceIdParamName).value(relatedResourceId).ignoreIfNull() + .longGreaterThanZero(); + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateNoRelatedResourceId(final JsonElement element) { + if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.relatedResourceIdParamName, element)) { + throw new UnsupportedParameterException(List.of(WorkingCapitalLoanConstants.relatedResourceIdParamName)); + } + } + public void validateDiscountTransaction(final WorkingCapitalLoan loan, final String json, BigDecimal discountAmount, final String note) { if (StringUtils.isBlank(json)) { 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 e4665850f8f..edff8b9f6aa 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 @@ -37,8 +37,12 @@ public interface WorkingCapitalLoanWritePlatformService { CommandProcessingResult makeDiscountFee(Long resourceId, JsonCommand command); + CommandProcessingResult makeDiscountFee(Long loanId, Long relatedDisbursementTransactionId, JsonCommand command); + CommandProcessingResult makeDiscountFeeAdjustment(Long loanId, JsonCommand command); + CommandProcessingResult makeDiscountFeeAdjustment(Long loanId, Long relatedDiscountTransactionId, JsonCommand command); + CommandProcessingResult undoTransaction(Long loanId, Long transactionId, JsonCommand command); CommandProcessingResult creditBalanceRefund(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 bcbad2fbeda..c4dc8577c1e 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 @@ -517,11 +517,25 @@ private WorkingCapitalLoanTransaction createAndPersistDiscountFeeTransaction(fin } @Override - public CommandProcessingResult makeDiscountFee(Long loanId, JsonCommand command) { + public CommandProcessingResult makeDiscountFee(final Long loanId, final JsonCommand command) { final WorkingCapitalLoan loan = loanRepository.findById(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); - + validator.validateRelatedResourceId(command.parsedJson()); final Long relatedDisbursementTransactionId = fromApiJsonHelper .extractLongNamed(WorkingCapitalLoanConstants.relatedResourceIdParamName, command.parsedJson()); + return makeDiscountFee(loan, relatedDisbursementTransactionId, command); + } + + @Override + public CommandProcessingResult makeDiscountFee(final Long loanId, final Long relatedDisbursementTransactionId, + final JsonCommand command) { + final WorkingCapitalLoan loan = loanRepository.findById(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + validator.validateNoRelatedResourceId(command.parsedJson()); + return makeDiscountFee(loan, relatedDisbursementTransactionId, command); + } + + private CommandProcessingResult makeDiscountFee(final WorkingCapitalLoan loan, final Long relatedDisbursementTransactionId, + final JsonCommand command) { + final Long loanId = loan.getId(); BigDecimal amount = fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, command.parsedJson(), new HashSet<>()); @@ -607,8 +621,23 @@ public CommandProcessingResult makeDiscountFee(Long loanId, JsonCommand command) @Override public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, final JsonCommand command) { final WorkingCapitalLoan loan = loanRepository.findById(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + validator.validateRelatedResourceId(command.parsedJson()); final Long relatedDiscountTransactionId = fromApiJsonHelper.extractLongNamed(WorkingCapitalLoanConstants.relatedResourceIdParamName, command.parsedJson()); + return makeDiscountFeeAdjustment(loan, relatedDiscountTransactionId, command); + } + + @Override + public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, final Long relatedDiscountTransactionId, + final JsonCommand command) { + final WorkingCapitalLoan loan = loanRepository.findById(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + validator.validateNoRelatedResourceId(command.parsedJson()); + return makeDiscountFeeAdjustment(loan, relatedDiscountTransactionId, command); + } + + private CommandProcessingResult makeDiscountFeeAdjustment(final WorkingCapitalLoan loan, final Long relatedDiscountTransactionId, + final JsonCommand command) { + final Long loanId = loan.getId(); if (relatedDiscountTransactionId == null) { throw new PlatformApiDataValidationException("validation.msg.wc.loan.related.resource.id.required", "Related discount transaction ID is required for discount fee adjustment", diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeRelatedTransactionInPathTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeRelatedTransactionInPathTest.java new file mode 100644 index 00000000000..df8e8d26ebd --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDiscountFeeRelatedTransactionInPathTest.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WorkingCapitalLoanDiscountFeeRelatedTransactionInPathTest { + + private static final Long LOAN_ID = 1L; + private static final Long RELATED_TRANSACTION_ID = 2L; + + private final JsonCommand commandWithRelatedResourceId = JsonCommand.fromJsonElement(LOAN_ID, + new FromJsonHelper().parse("{\"relatedResourceId\":2}")); + + @Mock + private WorkingCapitalLoanRepository loanRepository; + + @Spy + private WorkingCapitalLoanDataValidator validator = new WorkingCapitalLoanDataValidator(new FromJsonHelper(), null, null, null, null, + null, null); + + @InjectMocks + private WorkingCapitalLoanWritePlatformServiceImpl service; + + @BeforeEach + void loanExists() { + when(loanRepository.findById(LOAN_ID)).thenReturn(Optional.of(new WorkingCapitalLoan())); + } + + @Test + void discountFeeRejectsRelatedResourceIdInBodyWhenThePathNamesTheDisbursement() { + final UnsupportedParameterException exception = assertThrows(UnsupportedParameterException.class, + () -> service.makeDiscountFee(LOAN_ID, RELATED_TRANSACTION_ID, commandWithRelatedResourceId)); + + assertThat(exception.getUnsupportedParameters()).containsExactly(WorkingCapitalLoanConstants.relatedResourceIdParamName); + verify(validator).validateNoRelatedResourceId(commandWithRelatedResourceId.parsedJson()); + } + + @Test + void discountFeeAdjustmentRejectsRelatedResourceIdInBodyWhenThePathNamesTheDiscountFee() { + final UnsupportedParameterException exception = assertThrows(UnsupportedParameterException.class, + () -> service.makeDiscountFeeAdjustment(LOAN_ID, RELATED_TRANSACTION_ID, commandWithRelatedResourceId)); + + assertThat(exception.getUnsupportedParameters()).containsExactly(WorkingCapitalLoanConstants.relatedResourceIdParamName); + verify(validator).validateNoRelatedResourceId(commandWithRelatedResourceId.parsedJson()); + } +}