This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 9dad940236 FINERACT-2455: working capital loan approval/rejection
9dad940236 is described below
commit 9dad940236eb9c3f5f180bff9b3fff224620077e
Author: Attila Budai <[email protected]>
AuthorDate: Thu Mar 5 15:32:09 2026 +0100
FINERACT-2455: working capital loan approval/rejection
---
.../commands/service/CommandWrapperBuilder.java | 24 ++
.../WorkingCapitalLoanConstants.java | 9 +
.../api/WorkingCapitalLoanApiResource.java | 57 +++
.../api/WorkingCapitalLoanApiResourceSwagger.java | 41 ++
.../domain/WorkingCapitalLoanEvent.java | 26 ++
.../WorkingCapitalLoanLifecycleStateMachine.java | 50 +++
.../ApproveWorkingCapitalLoanCommandHandler.java | 42 ++
.../RejectWorkingCapitalLoanCommandHandler.java | 42 ++
...ndoApproveWorkingCapitalLoanCommandHandler.java | 42 ++
.../WorkingCapitalLoanDataValidator.java | 183 ++++++++
.../service/WorkingCapitalLoanAssemblerImpl.java | 4 +-
.../WorkingCapitalLoanWritePlatformService.java | 31 ++
...WorkingCapitalLoanWritePlatformServiceImpl.java | 190 ++++++++
.../workingcapitalloan/module-changelog-master.xml | 1 +
.../parts/0010_loan_account_permissions.xml | 71 +++
.../WorkingCapitalLoanApprovalRejectionTest.java | 479 +++++++++++++++++++++
.../WorkingCapitalLoanApplicationHelper.java | 47 ++
.../WorkingCapitalLoanApplicationTestBuilder.java | 42 ++
18 files changed, 1379 insertions(+), 2 deletions(-)
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 a25e595b0c..b7cd3cd6f4 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
@@ -594,6 +594,30 @@ public class CommandWrapperBuilder {
return this;
}
+ public CommandWrapperBuilder approveWorkingCapitalLoanApplication(final
Long loanId) {
+ this.actionName = "APPROVE";
+ this.entityName = "WORKINGCAPITALLOAN";
+ this.entityId = loanId;
+ this.href = "/workingcapitalloans/" + loanId;
+ return this;
+ }
+
+ public CommandWrapperBuilder rejectWorkingCapitalLoanApplication(final
Long loanId) {
+ this.actionName = "REJECT";
+ this.entityName = "WORKINGCAPITALLOAN";
+ this.entityId = loanId;
+ this.href = "/workingcapitalloans/" + loanId;
+ return this;
+ }
+
+ public CommandWrapperBuilder
undoWorkingCapitalLoanApplicationApproval(final Long loanId) {
+ this.actionName = "APPROVALUNDO";
+ this.entityName = "WORKINGCAPITALLOAN";
+ this.entityId = loanId;
+ this.href = "/workingcapitalloans/" + loanId;
+ return this;
+ }
+
public CommandWrapperBuilder createClientIdentifier(final Long clientId) {
this.actionName = "CREATE";
this.entityName = "CLIENTIDENTIFIER";
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 9bc368b2cc..2b96ea695d 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
@@ -46,4 +46,13 @@ public final class WorkingCapitalLoanConstants {
// Loan commands
public static final String APPROVE_LOAN_COMMAND = "approve";
public static final String DISBURSE_LOAN_COMMAND = "disburse";
+
+ // Approval / Rejection / Undo-approval parameters
+ public static final String RESOURCE_NAME = WCL_RESOURCE_NAME;
+ public static final String approvedOnDateParamName = "approvedOnDate";
+ public static final String approvedLoanAmountParamName =
"approvedLoanAmount";
+ public static final String expectedDisbursementDateParamName =
"expectedDisbursementDate";
+ public static final String discountAmountParamName = "discountAmount";
+ public static final String noteParamName = "note";
+ public static final String rejectedOnDateParamName = "rejectedOnDate";
}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java
index 1b18646567..68cb75af5e 100644
---
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java
@@ -43,6 +43,8 @@ import
org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.api.jersey.Pagination;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
+import
org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
+import org.apache.fineract.infrastructure.core.service.CommandParameterUtil;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import
org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import
org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
@@ -190,6 +192,36 @@ public class WorkingCapitalLoanApiResource {
return deleteLoanApplication(null, loanExternalId);
}
+ @POST
+ @Path("{loanId}")
+ @Consumes({ MediaType.APPLICATION_JSON })
+ @Produces({ MediaType.APPLICATION_JSON })
+ @Operation(operationId = "stateTransitionWorkingCapitalLoanById", summary
= "Approve/Reject/Undo-approve a Working Capital Loan", description =
"Mandatory command query parameter: approve, reject, or undoapproval.")
+ @RequestBody(required = true, content = @Content(schema =
@Schema(implementation =
WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdRequest.class)))
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "OK", content =
@Content(schema = @Schema(implementation =
WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdResponse.class)))
})
+ public CommandProcessingResult stateTransitionById(
+ @PathParam("loanId") @Parameter(description = "loanId", required =
true) final Long loanId,
+ @QueryParam("command") @Parameter(description = "command",
required = true) final String commandParam,
+ @Parameter(hidden = true) final String apiRequestBodyAsJson) {
+ return handleStateTransition(loanId, null, commandParam,
apiRequestBodyAsJson);
+ }
+
+ @POST
+ @Path("external-id/{loanExternalId}")
+ @Consumes({ MediaType.APPLICATION_JSON })
+ @Produces({ MediaType.APPLICATION_JSON })
+ @Operation(operationId = "stateTransitionWorkingCapitalLoanByExternalId",
summary = "Approve/Reject/Undo-approve a Working Capital Loan by external id",
description = "Mandatory command query parameter: approve, reject, or
undoapproval.")
+ @RequestBody(required = true, content = @Content(schema =
@Schema(implementation =
WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdRequest.class)))
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "OK", content =
@Content(schema = @Schema(implementation =
WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdResponse.class)))
})
+ public CommandProcessingResult stateTransitionByExternalId(
+ @PathParam("loanExternalId") @Parameter(description =
"loanExternalId", required = true) final String loanExternalId,
+ @QueryParam("command") @Parameter(description = "command",
required = true) final String commandParam,
+ @Parameter(hidden = true) final String apiRequestBodyAsJson) {
+ return handleStateTransition(null, loanExternalId, commandParam,
apiRequestBodyAsJson);
+ }
+
private CommandProcessingResult modifyLoanApplication(final Long loanId,
final String loanExternalIdStr,
final String apiRequestBodyAsJson) {
final Long resolvedLoanId = loanId != null ? loanId
@@ -212,4 +244,29 @@ public class WorkingCapitalLoanApiResource {
.build();
return
this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
+
+ private CommandProcessingResult handleStateTransition(final Long loanId,
final String loanExternalIdStr, final String commandParam,
+ final String apiRequestBodyAsJson) {
+ final Long resolvedLoanId = loanId != null ? loanId
+ :
readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr));
+ if (resolvedLoanId == null) {
+ throw new
WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalIdStr));
+ }
+
+ final CommandWrapperBuilder builder = new
CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
+ CommandWrapper commandRequest = null;
+ if (CommandParameterUtil.is(commandParam, "approve")) {
+ commandRequest =
builder.approveWorkingCapitalLoanApplication(resolvedLoanId).build();
+ } else if (CommandParameterUtil.is(commandParam, "reject")) {
+ commandRequest =
builder.rejectWorkingCapitalLoanApplication(resolvedLoanId).build();
+ } else if (CommandParameterUtil.is(commandParam, "undoapproval")) {
+ commandRequest =
builder.undoWorkingCapitalLoanApplicationApproval(resolvedLoanId).build();
+ }
+
+ if (commandRequest == null) {
+ 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/api/WorkingCapitalLoanApiResourceSwagger.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java
index 881a9881f1..18b987b278 100644
---
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java
@@ -397,4 +397,45 @@ public final class WorkingCapitalLoanApiResourceSwagger {
@Schema(example = "1")
public Long resourceId;
}
+
+ @Schema(description = "PostWorkingCapitalLoansLoanIdResponse")
+ public static final class PostWorkingCapitalLoansLoanIdResponse {
+
+ private PostWorkingCapitalLoansLoanIdResponse() {}
+
+ @Schema(example = "2")
+ public Long officeId;
+ @Schema(example = "6")
+ public Long clientId;
+ @Schema(example = "3")
+ public Long loanId;
+ @Schema(example = "3")
+ public Long resourceId;
+ @Schema(example = "95174ff9-1a75-4d72-a413-6f9b1cb988b7")
+ public String resourceExternalId;
+ public Object changes;
+ }
+
+ @Schema(description = "PostWorkingCapitalLoansLoanIdRequest")
+ public static final class PostWorkingCapitalLoansLoanIdRequest {
+
+ private PostWorkingCapitalLoansLoanIdRequest() {}
+
+ @Schema(example = "15 January 2024", description = "Date of approval")
+ public String approvedOnDate;
+ @Schema(example = "10000.00", description = "Approved principal amount
(optional, defaults to proposed principal)")
+ public BigDecimal approvedLoanAmount;
+ @Schema(example = "1 February 2024", description = "Expected
disbursement date")
+ public String expectedDisbursementDate;
+ @Schema(example = "0.0", description = "Discount amount (cannot exceed
creation-time discount)")
+ public BigDecimal discountAmount;
+ @Schema(example = "15 January 2024", description = "Date of rejection")
+ public String rejectedOnDate;
+ @Schema(example = "Approval/Rejection note")
+ public String note;
+ @Schema(example = "en_GB")
+ public String locale;
+ @Schema(example = "dd MMMM yyyy")
+ public String dateFormat;
+ }
}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java
new file mode 100644
index 0000000000..58c398ab15
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java
@@ -0,0 +1,26 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.domain;
+
+public enum WorkingCapitalLoanEvent {
+
+ LOAN_APPROVED, //
+ LOAN_APPROVAL_UNDO, //
+ LOAN_REJECTED //
+}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java
new file mode 100644
index 0000000000..0d7bae6488
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java
@@ -0,0 +1,50 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.domain;
+
+import
org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
+import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
+import org.springframework.stereotype.Component;
+
+@Component
+public class WorkingCapitalLoanLifecycleStateMachine {
+
+ public void transition(final WorkingCapitalLoanEvent event, final
WorkingCapitalLoan loan) {
+ LoanStatus newStatus = getNextStatus(event, loan);
+ if (newStatus != null) {
+ loan.setLoanStatus(newStatus);
+ } else {
+ throw new
PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed",
+ "Transition " + event + " is not allowed from status " +
loan.getLoanStatus(), "loanStatus");
+ }
+ }
+
+ private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event,
final WorkingCapitalLoan loan) {
+ LoanStatus from = loan.getLoanStatus();
+ if (from == null) {
+ return null;
+ }
+
+ return switch (event) {
+ case LOAN_APPROVED -> from.isSubmittedAndPendingApproval() ?
LoanStatus.APPROVED : null;
+ case LOAN_APPROVAL_UNDO -> from.isApproved() ?
LoanStatus.SUBMITTED_AND_PENDING_APPROVAL : null;
+ case LOAN_REJECTED -> from.isSubmittedAndPendingApproval() ?
LoanStatus.REJECTED : null;
+ };
+ }
+}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/ApproveWorkingCapitalLoanCommandHandler.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/ApproveWorkingCapitalLoanCommandHandler.java
new file mode 100644
index 0000000000..ca08d91218
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/ApproveWorkingCapitalLoanCommandHandler.java
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.handler;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.commands.annotation.CommandType;
+import org.apache.fineract.commands.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;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@CommandType(entity = "WORKINGCAPITALLOAN", action = "APPROVE")
+public class ApproveWorkingCapitalLoanCommandHandler implements
NewCommandSourceHandler {
+
+ private final WorkingCapitalLoanWritePlatformService writePlatformService;
+
+ @Transactional
+ @Override
+ public CommandProcessingResult processCommand(final JsonCommand command) {
+ return
this.writePlatformService.approveApplication(command.entityId(), command);
+ }
+}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/RejectWorkingCapitalLoanCommandHandler.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/RejectWorkingCapitalLoanCommandHandler.java
new file mode 100644
index 0000000000..4573053f9c
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/RejectWorkingCapitalLoanCommandHandler.java
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.handler;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.commands.annotation.CommandType;
+import org.apache.fineract.commands.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;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@CommandType(entity = "WORKINGCAPITALLOAN", action = "REJECT")
+public class RejectWorkingCapitalLoanCommandHandler implements
NewCommandSourceHandler {
+
+ private final WorkingCapitalLoanWritePlatformService writePlatformService;
+
+ @Transactional
+ @Override
+ public CommandProcessingResult processCommand(final JsonCommand command) {
+ return this.writePlatformService.rejectApplication(command.entityId(),
command);
+ }
+}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoApproveWorkingCapitalLoanCommandHandler.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoApproveWorkingCapitalLoanCommandHandler.java
new file mode 100644
index 0000000000..d1bd9d0162
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoApproveWorkingCapitalLoanCommandHandler.java
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.handler;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.commands.annotation.CommandType;
+import org.apache.fineract.commands.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;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@CommandType(entity = "WORKINGCAPITALLOAN", action = "APPROVALUNDO")
+public class UndoApproveWorkingCapitalLoanCommandHandler implements
NewCommandSourceHandler {
+
+ private final WorkingCapitalLoanWritePlatformService writePlatformService;
+
+ @Transactional
+ @Override
+ public CommandProcessingResult processCommand(final JsonCommand command) {
+ return
this.writePlatformService.undoApplicationApproval(command.entityId(), 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
new file mode 100644
index 0000000000..ea926f23e5
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java
@@ -0,0 +1,183 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.serialization;
+
+import com.google.gson.JsonElement;
+import com.google.gson.reflect.TypeToken;
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.fineract.infrastructure.core.data.ApiParameterError;
+import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
+import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
+import
org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
+import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
+import
org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
+import
org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class WorkingCapitalLoanDataValidator {
+
+ private final FromJsonHelper fromApiJsonHelper;
+
+ // Per requirement: only principal, discount, approved date, expected
disbursement date, and notes
+ private static final Set<String> APPROVAL_SUPPORTED_PARAMETERS = new
HashSet<>(
+ Arrays.asList("locale", "dateFormat",
WorkingCapitalLoanConstants.approvedOnDateParamName,
+ WorkingCapitalLoanConstants.approvedLoanAmountParamName,
WorkingCapitalLoanConstants.expectedDisbursementDateParamName,
+ WorkingCapitalLoanConstants.discountAmountParamName,
WorkingCapitalLoanConstants.noteParamName));
+
+ private static final Set<String> REJECTION_SUPPORTED_PARAMETERS = new
HashSet<>(Arrays.asList("locale", "dateFormat",
+ WorkingCapitalLoanConstants.rejectedOnDateParamName,
WorkingCapitalLoanConstants.noteParamName));
+
+ private static final Set<String> UNDO_APPROVAL_SUPPORTED_PARAMETERS = new
HashSet<>(
+ Arrays.asList("locale", "dateFormat",
WorkingCapitalLoanConstants.noteParamName));
+
+ public void validateApproval(final String json, final WorkingCapitalLoan
loan) {
+ if (StringUtils.isBlank(json)) {
+ throw new InvalidJsonException();
+ }
+
+ final Type typeOfMap = new TypeToken<Map<String, Object>>()
{}.getType();
+ this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
APPROVAL_SUPPORTED_PARAMETERS);
+
+ final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
+ final DataValidatorBuilder baseDataValidator = new
DataValidatorBuilder(dataValidationErrors)
+ .resource(WorkingCapitalLoanConstants.RESOURCE_NAME);
+
+ final JsonElement element = this.fromApiJsonHelper.parse(json);
+
+ // approvedOnDate is mandatory
+ final LocalDate approvedOnDate =
this.fromApiJsonHelper.extractLocalDateNamed(WorkingCapitalLoanConstants.approvedOnDateParamName,
+ element);
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.approvedOnDateParamName).value(approvedOnDate).notNull();
+
+ if (approvedOnDate != null) {
+ if (DateUtils.isDateInTheFuture(approvedOnDate)) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.approvedOnDateParamName)
+ .failWithCode("cannot.be.a.future.date");
+ }
+
+ if (loan.getSubmittedOnDate() != null &&
DateUtils.isBefore(approvedOnDate, loan.getSubmittedOnDate())) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.approvedOnDateParamName)
+ .failWithCode("cannot.be.before.submittal.date");
+ }
+ }
+
+ // approvedLoanAmount must be positive and <= proposedPrincipal
+ if
(this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.approvedLoanAmountParamName,
element)) {
+ final BigDecimal approvedLoanAmount = this.fromApiJsonHelper
+
.extractBigDecimalNamed(WorkingCapitalLoanConstants.approvedLoanAmountParamName,
element, new HashSet<>());
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.approvedLoanAmountParamName).value(approvedLoanAmount)
+ .ignoreIfNull().positiveAmount();
+
+ if (approvedLoanAmount != null && loan.getProposedPrincipal() !=
null
+ &&
approvedLoanAmount.compareTo(loan.getProposedPrincipal()) > 0) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.approvedLoanAmountParamName)
+
.failWithCode("amount.cannot.exceed.proposed.principal");
+ }
+ }
+
+ // expectedDisbursementDate is mandatory
+ final LocalDate expectedDisbursementDate = this.fromApiJsonHelper
+
.extractLocalDateNamed(WorkingCapitalLoanConstants.expectedDisbursementDateParamName,
element);
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.expectedDisbursementDateParamName).value(expectedDisbursementDate)
+ .notNull();
+ if (expectedDisbursementDate != null && approvedOnDate != null &&
DateUtils.isBefore(expectedDisbursementDate, approvedOnDate)) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.expectedDisbursementDateParamName)
+ .failWithCode("cannot.be.before.approval.date");
+ }
+
+ // discountAmount must be >= 0 and <= current (creation-time) discount
+ if
(this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.discountAmountParamName,
element)) {
+ final BigDecimal discountAmount = this.fromApiJsonHelper
+
.extractBigDecimalNamed(WorkingCapitalLoanConstants.discountAmountParamName,
element, new HashSet<>());
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.discountAmountParamName).value(discountAmount).ignoreIfNull()
+ .zeroOrPositiveAmount();
+
+ final BigDecimal currentDiscount =
loan.getLoanProductRelatedDetails() != null
+ ? loan.getLoanProductRelatedDetails().getDiscount()
+ : null;
+ if (discountAmount != null && currentDiscount != null &&
discountAmount.compareTo(currentDiscount) > 0) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.discountAmountParamName)
+ .failWithCode("amount.cannot.exceed.created.discount");
+ }
+ }
+
+ throwExceptionIfValidationWarningsExist(dataValidationErrors);
+ }
+
+ public void validateRejection(final String json, final WorkingCapitalLoan
loan) {
+ if (StringUtils.isBlank(json)) {
+ throw new InvalidJsonException();
+ }
+
+ final Type typeOfMap = new TypeToken<Map<String, Object>>()
{}.getType();
+ this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
REJECTION_SUPPORTED_PARAMETERS);
+
+ final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
+ final DataValidatorBuilder baseDataValidator = new
DataValidatorBuilder(dataValidationErrors)
+ .resource(WorkingCapitalLoanConstants.RESOURCE_NAME);
+
+ final JsonElement element = this.fromApiJsonHelper.parse(json);
+
+ final LocalDate rejectedOnDate =
this.fromApiJsonHelper.extractLocalDateNamed(WorkingCapitalLoanConstants.rejectedOnDateParamName,
+ element);
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.rejectedOnDateParamName).value(rejectedOnDate).notNull();
+
+ if (rejectedOnDate != null) {
+ if (DateUtils.isDateInTheFuture(rejectedOnDate)) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.rejectedOnDateParamName)
+ .failWithCode("cannot.be.a.future.date");
+ }
+
+ if (loan.getSubmittedOnDate() != null &&
DateUtils.isBefore(rejectedOnDate, loan.getSubmittedOnDate())) {
+
baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.rejectedOnDateParamName)
+ .failWithCode("cannot.be.before.submittal.date");
+ }
+ }
+
+ throwExceptionIfValidationWarningsExist(dataValidationErrors);
+ }
+
+ public void validateUndoApproval(final String json) {
+ if (StringUtils.isBlank(json)) {
+ return;
+ }
+
+ final Type typeOfMap = new TypeToken<Map<String, Object>>()
{}.getType();
+ this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
UNDO_APPROVAL_SUPPORTED_PARAMETERS);
+ }
+
+ private void throwExceptionIfValidationWarningsExist(final
List<ApiParameterError> dataValidationErrors) {
+ if (!dataValidationErrors.isEmpty()) {
+ throw new PlatformApiDataValidationException(dataValidationErrors);
+ }
+ }
+}
diff --git
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java
index 10ada48d13..8a5f96c78f 100644
---
a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java
@@ -129,7 +129,7 @@ public class WorkingCapitalLoanAssemblerImpl implements
WorkingCapitalLoanAssemb
loan.getDisbursementDetails().add(detail);
}
loan.setProposedPrincipal(principal);
- loan.setApprovedPrincipal(principal);
+ loan.setApprovedPrincipal(BigDecimal.ZERO);
final WorkingCapitalLoanBalance balance =
WorkingCapitalLoanBalance.createFor(loan);
balance.setPrincipalOutstanding(principal != null ? principal :
BigDecimal.ZERO);
balance.setTotalPayment(totalPayment != null ? totalPayment :
BigDecimal.ZERO);
@@ -249,7 +249,7 @@ public class WorkingCapitalLoanAssemblerImpl implements
WorkingCapitalLoanAssemb
final BigDecimal principal = fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(WorkingCapitalLoanConstants.principalAmountParamName,
element);
loan.setProposedPrincipal(principal);
- loan.setApprovedPrincipal(principal);
+ loan.setApprovedPrincipal(BigDecimal.ZERO);
ensureBalance(loan).setPrincipalOutstanding(principal != null ?
principal : BigDecimal.ZERO);
changes.put(WorkingCapitalLoanConstants.principalAmountParamName,
principal);
}
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
new file mode 100644
index 0000000000..5b22ea9a73
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java
@@ -0,0 +1,31 @@
+/**
+ * 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 org.apache.fineract.infrastructure.core.api.JsonCommand;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+
+public interface WorkingCapitalLoanWritePlatformService {
+
+ CommandProcessingResult approveApplication(Long loanId, JsonCommand
command);
+
+ CommandProcessingResult undoApplicationApproval(Long loanId, JsonCommand
command);
+
+ CommandProcessingResult rejectApplication(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
new file mode 100644
index 0000000000..a93cd5cac5
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java
@@ -0,0 +1,190 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.workingcapitalloan.service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.fineract.infrastructure.core.api.JsonCommand;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
+import
org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
+import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
+import
org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import
org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
+import
org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
+import
org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent;
+import
org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine;
+import
org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote;
+import
org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException;
+import
org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository;
+import
org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository;
+import
org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator;
+import
org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct;
+import
org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WorkingCapitalLoanWritePlatformServiceImpl implements
WorkingCapitalLoanWritePlatformService {
+
+ private final PlatformSecurityContext context;
+ private final WorkingCapitalLoanRepository loanRepository;
+ private final WorkingCapitalLoanDataValidator validator;
+ private final WorkingCapitalLoanLifecycleStateMachine stateMachine;
+ private final FromJsonHelper fromApiJsonHelper;
+ private final WorkingCapitalLoanNoteRepository noteRepository;
+
+ @Override
+ public CommandProcessingResult approveApplication(final Long loanId, final
JsonCommand command) {
+ final WorkingCapitalLoan loan = this.loanRepository.findById(loanId)
+ .orElseThrow(() -> new
WorkingCapitalLoanNotFoundException(loanId));
+
+ this.validator.validateApproval(command.json(), loan);
+
+ final AppUser currentUser = this.context.authenticatedUser();
+
+ this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_APPROVED,
loan);
+
+ // Approved date
+ final LocalDate approvedOnDate =
command.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.approvedOnDateParamName);
+ loan.setApprovedOnDate(approvedOnDate);
+ loan.setApprovedBy(currentUser);
+
+ // Principal amount (optional, defaults to proposed)
+ if
(command.parameterExists(WorkingCapitalLoanConstants.approvedLoanAmountParamName))
{
+ final BigDecimal approvedAmount = this.fromApiJsonHelper
+
.extractBigDecimalNamed(WorkingCapitalLoanConstants.approvedLoanAmountParamName,
command.parsedJson(), new HashSet<>());
+ if (approvedAmount != null) {
+ loan.setApprovedPrincipal(approvedAmount);
+ }
+ }
+ if (loan.getApprovedPrincipal() == null) {
+ loan.setApprovedPrincipal(loan.getProposedPrincipal());
+ }
+
+ // Expected disbursement date (mandatory, validated)
+ final LocalDate expectedDisbursementDate = command
+
.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.expectedDisbursementDateParamName);
+ if (expectedDisbursementDate != null &&
!loan.getDisbursementDetails().isEmpty()) {
+
loan.getDisbursementDetails().getFirst().setExpectedDisbursementDate(expectedDisbursementDate);
+ }
+
+ // Discount amount (optional, can only be reduced per requirement)
+ if
(command.parameterExists(WorkingCapitalLoanConstants.discountAmountParamName)) {
+ final BigDecimal discount =
this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.discountAmountParamName,
+ command.parsedJson(), new HashSet<>());
+ if (discount != null) {
+ loan.getLoanProductRelatedDetails().setDiscount(discount);
+ }
+ }
+
+ this.loanRepository.saveAndFlush(loan);
+
+
createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName),
loan);
+
+ final Map<String, Object> changes = new LinkedHashMap<>();
+ changes.put(WorkingCapitalLoanConstants.approvedOnDateParamName,
approvedOnDate);
+ changes.put("status", loan.getLoanStatus());
+
+ log.debug("Working capital loan {} approved by user {}", loanId,
currentUser.getId());
+
+ return new
CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId)
+
.withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId())
+ .withLoanId(loanId).with(changes).build();
+ }
+
+ @Override
+ public CommandProcessingResult undoApplicationApproval(final Long loanId,
final JsonCommand command) {
+ final WorkingCapitalLoan loan = this.loanRepository.findById(loanId)
+ .orElseThrow(() -> new
WorkingCapitalLoanNotFoundException(loanId));
+
+ this.validator.validateUndoApproval(command.json());
+
+
this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_APPROVAL_UNDO, loan);
+
+ loan.setApprovedOnDate(null);
+ loan.setApprovedBy(null);
+ loan.setApprovedPrincipal(BigDecimal.ZERO);
+
+ // Reset discount to product default.
+ // Note: if discount was customized at submission time, it resets to
product default,
+ // not the submission-time value, because we don't store a
pre-approval snapshot.
+ // The loan is back in SUBMITTED state and can be modified.
+ final WorkingCapitalLoanProduct product = loan.getLoanProduct();
+ final WorkingCapitalLoanProductRelatedDetail productDetail =
product.getRelatedDetail();
+
loan.getLoanProductRelatedDetails().setDiscount(productDetail.getDiscount());
+
+ this.loanRepository.saveAndFlush(loan);
+
+
createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName),
loan);
+
+ final Map<String, Object> changes = new LinkedHashMap<>();
+ changes.put("status", loan.getLoanStatus());
+
+ log.debug("Working capital loan {} approval undone", loanId);
+
+ return new
CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId)
+
.withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId())
+ .withLoanId(loanId).with(changes).build();
+ }
+
+ @Override
+ public CommandProcessingResult rejectApplication(final Long loanId, final
JsonCommand command) {
+ final WorkingCapitalLoan loan = this.loanRepository.findById(loanId)
+ .orElseThrow(() -> new
WorkingCapitalLoanNotFoundException(loanId));
+
+ this.validator.validateRejection(command.json(), loan);
+
+ final AppUser currentUser = this.context.authenticatedUser();
+
+ this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REJECTED,
loan);
+
+ final LocalDate rejectedOnDate =
command.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.rejectedOnDateParamName);
+ loan.setRejectedOnDate(rejectedOnDate);
+ loan.setRejectedBy(currentUser);
+
+ this.loanRepository.saveAndFlush(loan);
+
+
createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName),
loan);
+
+ final Map<String, Object> changes = new LinkedHashMap<>();
+ changes.put(WorkingCapitalLoanConstants.rejectedOnDateParamName,
rejectedOnDate);
+ changes.put("status", loan.getLoanStatus());
+
+ log.debug("Working capital loan {} rejected by user {}", loanId,
currentUser.getId());
+
+ return new
CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId)
+
.withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId())
+ .withLoanId(loanId).with(changes).build();
+ }
+
+ private void createNote(final String noteText, final WorkingCapitalLoan
loan) {
+ if (StringUtils.isNotBlank(noteText)) {
+ final WorkingCapitalLoanNote note =
WorkingCapitalLoanNote.create(loan, noteText);
+ this.noteRepository.save(note);
+ }
+ }
+}
diff --git
a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml
b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml
index d49e648c1d..0946a57d43 100644
---
a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml
+++
b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml
@@ -31,4 +31,5 @@
<include relativeToChangelogFile="true"
file="parts/0007_drop_flat_percentage_amount.xml"/>
<include relativeToChangelogFile="true"
file="parts/0008_delinquency_for_working_capital_loans.xml"/>
<include relativeToChangelogFile="true"
file="parts/0009_wc_loan_amortization_model.xml"/>
+ <include relativeToChangelogFile="true"
file="parts/0010_loan_account_permissions.xml"/>
</databaseChangeLog>
diff --git
a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0010_loan_account_permissions.xml
b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0010_loan_account_permissions.xml
new file mode 100644
index 0000000000..be470293f1
--- /dev/null
+++
b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0010_loan_account_permissions.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
+
+ <!-- Permissions for Working Capital Loan Account approval/rejection
operations -->
+ <changeSet author="fineract" id="wcl-0007-1">
+ <preConditions onFail="MARK_RAN">
+ <sqlCheck expectedResult="0">
+ SELECT COUNT(*) FROM m_permission WHERE code =
'APPROVE_WORKINGCAPITALLOAN';
+ </sqlCheck>
+ </preConditions>
+ <insert tableName="m_permission">
+ <column name="grouping" value="portfolio"/>
+ <column name="code" value="APPROVE_WORKINGCAPITALLOAN"/>
+ <column name="entity_name" value="WORKINGCAPITALLOAN"/>
+ <column name="action_name" value="APPROVE"/>
+ <column name="can_maker_checker" valueBoolean="false"/>
+ </insert>
+ </changeSet>
+
+ <changeSet author="fineract" id="wcl-0007-2">
+ <preConditions onFail="MARK_RAN">
+ <sqlCheck expectedResult="0">
+ SELECT COUNT(*) FROM m_permission WHERE code =
'REJECT_WORKINGCAPITALLOAN';
+ </sqlCheck>
+ </preConditions>
+ <insert tableName="m_permission">
+ <column name="grouping" value="portfolio"/>
+ <column name="code" value="REJECT_WORKINGCAPITALLOAN"/>
+ <column name="entity_name" value="WORKINGCAPITALLOAN"/>
+ <column name="action_name" value="REJECT"/>
+ <column name="can_maker_checker" valueBoolean="false"/>
+ </insert>
+ </changeSet>
+
+ <changeSet author="fineract" id="wcl-0007-3">
+ <preConditions onFail="MARK_RAN">
+ <sqlCheck expectedResult="0">
+ SELECT COUNT(*) FROM m_permission WHERE code =
'APPROVALUNDO_WORKINGCAPITALLOAN';
+ </sqlCheck>
+ </preConditions>
+ <insert tableName="m_permission">
+ <column name="grouping" value="portfolio"/>
+ <column name="code" value="APPROVALUNDO_WORKINGCAPITALLOAN"/>
+ <column name="entity_name" value="WORKINGCAPITALLOAN"/>
+ <column name="action_name" value="APPROVALUNDO"/>
+ <column name="can_maker_checker" valueBoolean="false"/>
+ </insert>
+ </changeSet>
+</databaseChangeLog>
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApprovalRejectionTest.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApprovalRejectionTest.java
new file mode 100644
index 0000000000..0159105fcb
--- /dev/null
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApprovalRejectionTest.java
@@ -0,0 +1,479 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import io.restassured.builder.RequestSpecBuilder;
+import io.restassured.builder.ResponseSpecBuilder;
+import io.restassured.http.ContentType;
+import io.restassured.specification.RequestSpecification;
+import io.restassured.specification.ResponseSpecification;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.UUID;
+import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
+import org.apache.fineract.integrationtests.common.ClientHelper;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.apache.fineract.integrationtests.common.funds.FundsResourceHandler;
+import
org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper;
+import
org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationHelper;
+import
org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder;
+import
org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper;
+import
org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+public class WorkingCapitalLoanApprovalRejectionTest {
+
+ private static RequestSpecification requestSpec;
+ private static ResponseSpecification responseSpec;
+ private static Long delinquencyBucketId;
+ private static Long fundId;
+
+ private final WorkingCapitalLoanApplicationHelper applicationHelper = new
WorkingCapitalLoanApplicationHelper();
+ private final WorkingCapitalLoanProductHelper productHelper = new
WorkingCapitalLoanProductHelper();
+
+ @BeforeAll
+ static void init() {
+ Utils.initializeRESTAssured();
+ requestSpec = new
RequestSpecBuilder().setContentType(ContentType.JSON).build();
+ requestSpec.header("Authorization", "Basic " +
Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
+ requestSpec.header("Fineract-Platform-TenantId", "default");
+ responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
+ delinquencyBucketId = DelinquencyBucketsHelper.createDefaultBucket();
+ fundId = (long) FundsResourceHandler.createFund(requestSpec,
responseSpec);
+ }
+
+ // ===== AC: User should be able to approve the created loan account (via
API) =====
+
+ @Test
+ public void testApproveWorkingCapitalLoan() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ final LocalDate approvedOnDate = getSubmittedOnDate(loanId);
+ applicationHelper.approveById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate));
+
+ final JsonObject data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.approved",
data.getAsJsonObject("status").get("code").getAsString());
+ assertDateEquals(approvedOnDate, data.get("approvedOnDate"));
+ // approvedPrincipal should default to proposedPrincipal
+ assertNotNull(data.get("approvedPrincipal"));
+ }
+
+ // ===== AC: Fields modifiable during approval: Principal, Discount, Date,
ExpDisbDate =====
+
+ @Test
+ public void testApproveWithPrincipalAndDiscountOverride() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+
+ // Submit with discount = 100
+ final Long loanId = applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .withDiscount(BigDecimal.valueOf(100)) //
+ .buildSubmitJson());
+
+ final LocalDate approvedOnDate = getSubmittedOnDate(loanId);
+ final BigDecimal approvedAmount = BigDecimal.valueOf(3000);
+ final BigDecimal discountAmount = BigDecimal.valueOf(50); // reduced
from 100 to 50
+
+ applicationHelper.approveById(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate,
approvedAmount, discountAmount));
+
+ final JsonObject data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.approved",
data.getAsJsonObject("status").get("code").getAsString());
+ assertEqualBigDecimal(approvedAmount, data.get("approvedPrincipal"));
+ assertEqualBigDecimal(discountAmount, data.get("discount"));
+ }
+
+ @Test
+ public void testRejectWorkingCapitalLoan() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ final LocalDate rejectedOnDate = getSubmittedOnDate(loanId);
+ applicationHelper.rejectById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildRejectJson(rejectedOnDate));
+
+ final JsonObject data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.rejected",
data.getAsJsonObject("status").get("code").getAsString());
+ assertDateEquals(rejectedOnDate, data.get("rejectedOnDate"));
+ }
+
+ // ===== AC: User should be able to undo the approval; moves back to
created state =====
+
+ @Test
+ public void testUndoApproval() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ applicationHelper.approveById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(getSubmittedOnDate(loanId)));
+
+ applicationHelper.undoApprovalById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildUndoApproveJson());
+
+ final JsonObject data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.submitted.and.pending.approval",
data.getAsJsonObject("status").get("code").getAsString());
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testUndoApprovalResetsToCreatedState() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+
+ // Submit with discount = 100
+ final Long loanId = applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .withDiscount(BigDecimal.valueOf(100)) //
+ .buildSubmitJson());
+
+ // Approve with reduced principal and discount
+ applicationHelper.approveById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(getSubmittedOnDate(loanId),
+ BigDecimal.valueOf(3000), BigDecimal.valueOf(50)));
+
+ final JsonObject approvedData = retrieveLoan(loanId);
+ assertEqualBigDecimal(BigDecimal.valueOf(3000),
approvedData.get("approvedPrincipal"));
+ assertEqualBigDecimal(BigDecimal.valueOf(50),
approvedData.get("discount"));
+
+ // Undo approval
+ applicationHelper.undoApprovalById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildUndoApproveJson());
+
+ final JsonObject undoData = retrieveLoan(loanId);
+ assertEquals("loanStatusType.submitted.and.pending.approval",
undoData.getAsJsonObject("status").get("code").getAsString());
+ // approvedPrincipal should reset to 0 after undo (loan is back in
submitted state, not yet approved)
+ assertEqualBigDecimal(BigDecimal.ZERO,
undoData.get("approvedPrincipal"));
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ // ========== State transition validation tests ==========
+
+ @Test
+ public void testApproveAlreadyApprovedLoanFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ final LocalDate submittedOnDate = getSubmittedOnDate(loanId);
+ applicationHelper.approveById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(submittedOnDate));
+
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(submittedOnDate));
+ assertNotNull(ex);
+ }
+
+ @Test
+ public void testRejectApprovedLoanFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ final LocalDate submittedOnDate = getSubmittedOnDate(loanId);
+ applicationHelper.approveById(loanId,
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(submittedOnDate));
+
+ CallFailedRuntimeException ex =
applicationHelper.runRejectExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildRejectJson(submittedOnDate));
+ assertNotNull(ex);
+ }
+
+ @Test
+ public void testUndoNonApprovedLoanFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ CallFailedRuntimeException ex =
applicationHelper.runUndoApprovalExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildUndoApproveJson());
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ // ========== Input validation tests ==========
+
+ @Test
+ public void testApproveWithoutApprovedOnDateFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(null));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testApproveWithFutureDateFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(getSubmittedOnDate(loanId).plusDays(10)));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testApproveWithDateBeforeSubmittedOnDateFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final LocalDate submittedOnDate =
LocalDate.now(ZoneId.systemDefault());
+
+ final Long loanId = applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .withSubmittedOnDate(submittedOnDate) //
+ .buildSubmitJson());
+
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(submittedOnDate.minusDays(1)));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testRejectWithoutRejectedOnDateFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ CallFailedRuntimeException ex =
applicationHelper.runRejectExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildRejectJson(null));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testApproveWithNegativeAmountFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(getSubmittedOnDate(loanId),
BigDecimal.valueOf(-100), null));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testApproveWithAmountExceedingProposedPrincipalFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId); // proposed
principal = 5000
+
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(getSubmittedOnDate(loanId),
BigDecimal.valueOf(6000), null));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testApproveWithoutExpectedDisbursementDateFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final Long loanId = submitLoan(clientId, productId);
+
+ // Build approve JSON without expectedDisbursementDate
+ final String json =
"{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\",\"approvedOnDate\":\"" +
getSubmittedOnDate(loanId) + "\"}";
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId, json);
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ @Test
+ public void testApproveWithDiscountExceedingCreatedValueFails() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+
+ // Submit with discount = 100
+ final Long loanId = applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .withDiscount(BigDecimal.valueOf(100)) //
+ .buildSubmitJson());
+
+ // Approve with discount = 200 (exceeds creation-time 100) → should
fail
+ CallFailedRuntimeException ex =
applicationHelper.runApproveExpectingFailure(loanId,
+
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(getSubmittedOnDate(loanId),
null, BigDecimal.valueOf(200)));
+ assertNotNull(ex);
+
+ applicationHelper.deleteById(loanId);
+ productHelper.deleteWorkingCapitalLoanProductById(productId);
+ }
+
+ // ========== External-ID endpoint tests ==========
+
+ @Test
+ public void testApproveAndUndoByExternalId() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final String externalId = UUID.randomUUID().toString();
+
+ final Long loanId = applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .withExternalId(externalId) //
+ .buildSubmitJson());
+
+ final LocalDate approvedOnDate = getSubmittedOnDate(loanId);
+ applicationHelper.approveByExternalId(externalId,
WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate));
+
+ JsonObject data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.approved",
data.getAsJsonObject("status").get("code").getAsString());
+
+ applicationHelper.undoApprovalByExternalId(externalId,
WorkingCapitalLoanApplicationTestBuilder.buildUndoApproveJson());
+
+ data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.submitted.and.pending.approval",
data.getAsJsonObject("status").get("code").getAsString());
+ }
+
+ @Test
+ public void testRejectByExternalId() {
+ final Long productId = createProduct();
+ final Long clientId = createClient();
+ final String externalId = UUID.randomUUID().toString();
+
+ final Long loanId = applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .withExternalId(externalId) //
+ .buildSubmitJson());
+
+ final LocalDate rejectedOnDate = getSubmittedOnDate(loanId);
+ applicationHelper.rejectByExternalId(externalId,
WorkingCapitalLoanApplicationTestBuilder.buildRejectJson(rejectedOnDate));
+
+ final JsonObject data = retrieveLoan(loanId);
+ assertEquals("loanStatusType.rejected",
data.getAsJsonObject("status").get("code").getAsString());
+ }
+
+ // ========== Helper methods ==========
+
+ private Long submitLoan(final Long clientId, final Long productId) {
+ return applicationHelper.submit(new
WorkingCapitalLoanApplicationTestBuilder() //
+ .withClientId(clientId) //
+ .withProductId(productId) //
+ .withPrincipal(BigDecimal.valueOf(5000)) //
+ .withPeriodPaymentRate(BigDecimal.ONE) //
+ .withTotalPayment(BigDecimal.valueOf(5500)) //
+ .buildSubmitJson());
+ }
+
+ private JsonObject retrieveLoan(final Long loanId) {
+ final String response = applicationHelper.retrieveById(loanId);
+ assertNotNull(response);
+ return new Gson().fromJson(response, JsonObject.class);
+ }
+
+ /**
+ * Retrieves the submittedOnDate from the server for the given loan. This
avoids timezone mismatches between the
+ * test JVM and the server (which uses the tenant timezone).
+ */
+ private LocalDate getSubmittedOnDate(final Long loanId) {
+ final JsonObject data = retrieveLoan(loanId);
+ return extractDate(data.get("submittedOnDate"));
+ }
+
+ private static LocalDate extractDate(final com.google.gson.JsonElement
element) {
+ assertNotNull(element, "Expected date element");
+ if (element.isJsonArray()) {
+ final com.google.gson.JsonArray arr = element.getAsJsonArray();
+ return LocalDate.of(arr.get(0).getAsInt(), arr.get(1).getAsInt(),
arr.get(2).getAsInt());
+ }
+ return LocalDate.parse(element.getAsString());
+ }
+
+ private Long createProduct() {
+ final String uniqueName = "WCL Product " +
UUID.randomUUID().toString().substring(0, 8);
+ final String uniqueShortName =
UUID.randomUUID().toString().replace("-", "").substring(0, 4);
+ return productHelper
+ .createWorkingCapitalLoanProduct(
+ new
WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build())
+ .getResourceId();
+ }
+
+ private Long createClient() {
+ return
ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
+ }
+
+ private static void assertEqualBigDecimal(final BigDecimal expected, final
com.google.gson.JsonElement actual) {
+ assertNotNull(actual, "Expected value for field");
+ assertEquals(0,
expected.compareTo(actual.getAsJsonPrimitive().getAsBigDecimal()),
+ "Expected " + expected + " but got " + actual.getAsString());
+ }
+
+ private static void assertDateEquals(final LocalDate expected, final
com.google.gson.JsonElement actual) {
+ assertNotNull(actual, "Expected date value");
+ if (actual.isJsonArray()) {
+ final com.google.gson.JsonArray arr = actual.getAsJsonArray();
+ assertEquals(expected.getYear(), arr.get(0).getAsInt());
+ assertEquals(expected.getMonthValue(), arr.get(1).getAsInt());
+ assertEquals(expected.getDayOfMonth(), arr.get(2).getAsInt());
+ } else {
+ assertEquals(expected.toString(), actual.getAsString());
+ }
+ }
+}
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java
index ad733527e6..de4853ce7d 100644
---
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java
@@ -26,6 +26,7 @@ import
org.apache.fineract.client.feign.services.WorkingCapitalLoansApi;
import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
import org.apache.fineract.client.feign.util.FeignCalls;
import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest;
import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest;
import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse;
import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest;
@@ -90,6 +91,52 @@ public class WorkingCapitalLoanApplicationHelper {
return toJson(response);
}
+ public Long approveById(final Long loanId, final String jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.ok(() ->
api().stateTransitionWorkingCapitalLoanById(loanId, "approve",
request)).getResourceId();
+ }
+
+ public Long rejectById(final Long loanId, final String jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.ok(() ->
api().stateTransitionWorkingCapitalLoanById(loanId, "reject",
request)).getResourceId();
+ }
+
+ public Long undoApprovalById(final Long loanId, final String jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.ok(() ->
api().stateTransitionWorkingCapitalLoanById(loanId, "undoapproval",
request)).getResourceId();
+ }
+
+ public Long approveByExternalId(final String externalId, final String
jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.ok(() ->
api().stateTransitionWorkingCapitalLoanByExternalId(externalId, "approve",
request)).getResourceId();
+ }
+
+ public Long rejectByExternalId(final String externalId, final String
jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.ok(() ->
api().stateTransitionWorkingCapitalLoanByExternalId(externalId, "reject",
request)).getResourceId();
+ }
+
+ public Long undoApprovalByExternalId(final String externalId, final String
jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.ok(() ->
api().stateTransitionWorkingCapitalLoanByExternalId(externalId, "undoapproval",
request))
+ .getResourceId();
+ }
+
+ public CallFailedRuntimeException runApproveExpectingFailure(final Long
loanId, final String jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.fail(() ->
api().stateTransitionWorkingCapitalLoanById(loanId, "approve", request));
+ }
+
+ public CallFailedRuntimeException runRejectExpectingFailure(final Long
loanId, final String jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.fail(() ->
api().stateTransitionWorkingCapitalLoanById(loanId, "reject", request));
+ }
+
+ public CallFailedRuntimeException runUndoApprovalExpectingFailure(final
Long loanId, final String jsonBody) {
+ PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody,
PostWorkingCapitalLoansLoanIdRequest.class);
+ return FeignCalls.fail(() ->
api().stateTransitionWorkingCapitalLoanById(loanId, "undoapproval", request));
+ }
+
/**
* For validation tests: run submit expecting failure.
*/
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java
index a2c88472b0..a55cc23046 100644
---
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationTestBuilder.java
@@ -242,6 +242,48 @@ public class WorkingCapitalLoanApplicationTestBuilder {
return json;
}
+ public static String buildApproveJson(final LocalDate approvedOnDate,
final BigDecimal approvedLoanAmount,
+ final BigDecimal discountAmount) {
+ final JsonObject json = new JsonObject();
+ json.addProperty("locale", DEFAULT_LOCALE);
+ json.addProperty("dateFormat", DEFAULT_DATE_FORMAT);
+ if (approvedOnDate != null) {
+ json.addProperty("approvedOnDate",
approvedOnDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
+ }
+ // expectedDisbursementDate is mandatory — default to 7 days after
approval
+ final LocalDate disbursementDate = approvedOnDate != null ?
approvedOnDate.plusDays(7)
+ : LocalDate.now(ZoneId.systemDefault()).plusDays(7);
+ json.addProperty("expectedDisbursementDate",
disbursementDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
+ if (approvedLoanAmount != null) {
+ json.addProperty("approvedLoanAmount", approvedLoanAmount);
+ }
+ if (discountAmount != null) {
+ json.addProperty("discountAmount", discountAmount);
+ }
+ return json.toString();
+ }
+
+ public static String buildApproveJson(final LocalDate approvedOnDate) {
+ return buildApproveJson(approvedOnDate, null, null);
+ }
+
+ public static String buildRejectJson(final LocalDate rejectedOnDate) {
+ final JsonObject json = new JsonObject();
+ json.addProperty("locale", DEFAULT_LOCALE);
+ json.addProperty("dateFormat", DEFAULT_DATE_FORMAT);
+ if (rejectedOnDate != null) {
+ json.addProperty("rejectedOnDate",
rejectedOnDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
+ }
+ return json.toString();
+ }
+
+ public static String buildUndoApproveJson() {
+ final JsonObject json = new JsonObject();
+ json.addProperty("locale", DEFAULT_LOCALE);
+ json.addProperty("dateFormat", DEFAULT_DATE_FORMAT);
+ return json.toString();
+ }
+
private JsonArray buildPaymentAllocationJson() {
final JsonArray paymentAllocation = new JsonArray();
final JsonObject rule = new JsonObject();