From 783585943410bc635061e2b459e98f4945fd2dff Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Thu, 5 Mar 2026 15:32:09 +0100 Subject: [PATCH 1/2] FINERACT-2455: working capital loan approval/rejection --- .../service/CommandWrapperBuilder.java | 24 + .../WorkingCapitalLoanConstants.java | 9 + .../api/WorkingCapitalLoanApiResource.java | 57 +++ .../WorkingCapitalLoanApiResourceSwagger.java | 41 ++ .../domain/WorkingCapitalLoanEvent.java | 26 + ...rkingCapitalLoanLifecycleStateMachine.java | 50 ++ ...proveWorkingCapitalLoanCommandHandler.java | 42 ++ ...ejectWorkingCapitalLoanCommandHandler.java | 42 ++ ...proveWorkingCapitalLoanCommandHandler.java | 42 ++ .../WorkingCapitalLoanDataValidator.java | 183 +++++++ ...orkingCapitalLoanWritePlatformService.java | 31 ++ ...ngCapitalLoanWritePlatformServiceImpl.java | 190 +++++++ .../module-changelog-master.xml | 1 + .../parts/0008_loan_account_permissions.xml | 71 +++ ...rkingCapitalLoanApprovalRejectionTest.java | 479 ++++++++++++++++++ .../WorkingCapitalLoanApplicationHelper.java | 47 ++ ...kingCapitalLoanApplicationTestBuilder.java | 42 ++ 17 files changed, 1377 insertions(+) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/ApproveWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/RejectWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoApproveWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_loan_account_permissions.xml create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApprovalRejectionTest.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 a25e595b0c4..b7cd3cd6f4f 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 CommandWrapperBuilder deleteWorkingCapitalLoanApplication() { 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 95266bdb5b2..d3656f141ca 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 @@ -42,4 +42,13 @@ private WorkingCapitalLoanConstants() { public static final String submittedOnNoteParameterName = "submittedOnNote"; public static final String totalPaymentParamName = "totalPayment"; public static final String principalAmountParamName = "principalAmount"; + + // 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 1b186465676..68cb75af5ec 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.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 CommandProcessingResult deleteLoanApplication( 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 @@ private CommandProcessingResult deleteLoanApplication(final Long loanId, final S .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 881a9881f19..18b987b2787 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 @@ private DeleteWorkingCapitalLoansLoanIdResponse() {} @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 00000000000..58c398ab158 --- /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 00000000000..0d7bae6488b --- /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 00000000000..ca08d912181 --- /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 00000000000..4573053f9c9 --- /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 00000000000..d1bd9d0162c --- /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 00000000000..ea926f23e50 --- /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 APPROVAL_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.approvedOnDateParamName, + WorkingCapitalLoanConstants.approvedLoanAmountParamName, WorkingCapitalLoanConstants.expectedDisbursementDateParamName, + WorkingCapitalLoanConstants.discountAmountParamName, WorkingCapitalLoanConstants.noteParamName)); + + private static final Set REJECTION_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("locale", "dateFormat", + WorkingCapitalLoanConstants.rejectedOnDateParamName, WorkingCapitalLoanConstants.noteParamName)); + + private static final Set 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>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, APPROVAL_SUPPORTED_PARAMETERS); + + final List 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>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, REJECTION_SUPPORTED_PARAMETERS); + + final List 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>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UNDO_APPROVAL_SUPPORTED_PARAMETERS); + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } +} 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 00000000000..5b22ea9a73e --- /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 00000000000..1f7e975fe38 --- /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 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(loan.getProposedPrincipal()); + + // 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 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 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 7119772bfbd..cb5a7f366cc 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 @@ -29,4 +29,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_loan_account_permissions.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_loan_account_permissions.xml new file mode 100644 index 00000000000..be470293f13 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_loan_account_permissions.xml @@ -0,0 +1,71 @@ + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_WORKINGCAPITALLOAN'; + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'REJECT_WORKINGCAPITALLOAN'; + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVALUNDO_WORKINGCAPITALLOAN'; + + + + + + + + + + + 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 00000000000..7923486d224 --- /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 = (long) DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec); + 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 proposedPrincipal + assertEqualBigDecimal(BigDecimal.valueOf(5000), 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 ad733527e60..de4853ce7de 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.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 String retrieveTemplateRaw(final Map queryParams) { 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 a2c88472b04..a55cc230469 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 @@ private JsonObject buildBaseJson() { 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(); From a86243737ae888055b4008353b04326844dd706b Mon Sep 17 00:00:00 2001 From: mariiaKraievska Date: Wed, 18 Mar 2026 14:24:00 +0200 Subject: [PATCH 2/2] FINERACT-2455: WC - Loan account Disbursement / Undo disbursement --- .../service/CommandWrapperBuilder.java | 16 + .../module/fineract-provider/persistence.xml | 3 + .../WorkingCapitalLoanConstants.java | 11 + .../api/WorkingCapitalLoanApiResource.java | 8 +- .../WorkingCapitalLoanApiResourceSwagger.java | 52 +- ...ingCapitalLoanTransactionsApiResource.java | 140 ++ ...talLoanTransactionsApiResourceSwagger.java | 115 ++ .../data/WorkingCapitalLoanData.java | 1 + .../WorkingCapitalLoanTransactionData.java | 54 + ...pitalLoanTransactionPaymentDetailData.java | 41 + .../domain/WorkingCapitalLoan.java | 5 + .../domain/WorkingCapitalLoanEvent.java | 4 +- ...rkingCapitalLoanLifecycleStateMachine.java | 6 + .../domain/WorkingCapitalLoanTransaction.java | 117 ++ ...rkingCapitalLoanTransactionAllocation.java | 71 + ...ngCapitalLoanTransactionPaymentDetail.java | 59 + ...pitalLoanTransactionNotFoundException.java | 39 + ...burseWorkingCapitalLoanCommandHandler.java | 42 + ...burseWorkingCapitalLoanCommandHandler.java | 42 + .../mapper/WorkingCapitalLoanMapper.java | 6 +- .../WorkingCapitalLoanTransactionMapper.java | 60 + .../WorkingCapitalLoanBalanceRepository.java | 28 + .../WorkingCapitalLoanRepository.java | 6 + ...alLoanTransactionAllocationRepository.java | 24 + ...oanTransactionPaymentDetailRepository.java | 25 + ...rkingCapitalLoanTransactionRepository.java | 40 + .../WorkingCapitalLoanDataValidator.java | 176 +++ ...talLoanTransactionReadPlatformService.java | 57 + ...oanTransactionReadPlatformServiceImpl.java | 107 ++ ...orkingCapitalLoanWritePlatformService.java | 4 + ...ngCapitalLoanWritePlatformServiceImpl.java | 213 +++ .../module-changelog-master.xml | 2 + .../parts/0008_wc_loan_transaction.xml | 282 ++++ .../0009_wc_loan_disbursement_permissions.xml | 56 + ...rkingCapitalLoanTransactionMapperTest.java | 106 ++ .../WorkingCapitalLoanDisbursementTest.java | 1216 +++++++++++++++++ .../WorkingCapitalLoanApplicationHelper.java | 25 +- ...ingCapitalLoanDisbursementTestBuilder.java | 108 ++ 38 files changed, 3357 insertions(+), 10 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_wc_loan_transaction.xml create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0009_wc_loan_disbursement_permissions.xml create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.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 b7cd3cd6f4f..801f25831ed 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 @@ -618,6 +618,22 @@ public CommandWrapperBuilder undoWorkingCapitalLoanApplicationApproval(final Lon return this; } + public CommandWrapperBuilder disburseWorkingCapitalLoanApplication(final Long loanId) { + this.actionName = "DISBURSE"; + this.entityName = "WORKINGCAPITALLOAN"; + this.entityId = loanId; + this.href = "/workingcapitalloans/" + loanId; + return this; + } + + public CommandWrapperBuilder undoWorkingCapitalLoanApplicationDisbursal(final Long loanId) { + this.actionName = "DISBURSALUNDO"; + 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-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml index 19f793c2b93..3b1284d2305 100644 --- a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml +++ b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml @@ -219,6 +219,9 @@ org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPaymentAllocationRule + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail org.apache.fineract.portfolio.loanproduct.domain.AllocationTypeListConverter 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 d3656f141ca..2084ce73bbb 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java @@ -51,4 +51,15 @@ private WorkingCapitalLoanConstants() { public static final String discountAmountParamName = "discountAmount"; public static final String noteParamName = "note"; public static final String rejectedOnDateParamName = "rejectedOnDate"; + + // Disbursal / Undo disbursal parameters + public static final String actualDisbursementDateParamName = "actualDisbursementDate"; + public static final String transactionAmountParamName = "transactionAmount"; + public static final String paymentDetailsParamName = "paymentDetails"; + public static final String paymentTypeIdParamName = "paymentTypeId"; + public static final String accountNumberParamName = "accountNumber"; + public static final String checkNumberParamName = "checkNumber"; + public static final String routingCodeParamName = "routingCode"; + public static final String receiptNumberParamName = "receiptNumber"; + public static final String bankNumberParamName = "bankNumber"; } 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 68cb75af5ec..e4c47400d39 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 @@ -196,7 +196,7 @@ public CommandProcessingResult deleteLoanApplication( @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.") + @Operation(operationId = "stateTransitionWorkingCapitalLoanById", summary = "Approve/Reject/Undo-approve/Disburse/Undo-disburse a Working Capital Loan", description = "Mandatory command query parameter: approve, reject, undoapproval, disburse, or undodisbursal.") @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))) }) @@ -211,7 +211,7 @@ public CommandProcessingResult stateTransitionById( @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.") + @Operation(operationId = "stateTransitionWorkingCapitalLoanByExternalId", summary = "Approve/Reject/Undo-approve/Disburse/Undo-disburse a Working Capital Loan by external id", description = "Mandatory command query parameter: approve, reject, undoapproval, disburse, or undodisbursal.") @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))) }) @@ -261,6 +261,10 @@ private CommandProcessingResult handleStateTransition(final Long loanId, final S commandRequest = builder.rejectWorkingCapitalLoanApplication(resolvedLoanId).build(); } else if (CommandParameterUtil.is(commandParam, "undoapproval")) { commandRequest = builder.undoWorkingCapitalLoanApplicationApproval(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, "disburse")) { + commandRequest = builder.disburseWorkingCapitalLoanApplication(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, "undodisbursal")) { + commandRequest = builder.undoWorkingCapitalLoanApplicationDisbursal(resolvedLoanId).build(); } if (commandRequest == null) { 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 18b987b2787..86140f6e900 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 @@ -197,6 +197,8 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public List disbursementDetails; /** Running balances (principal outstanding, total payment, etc.). */ public GetBalance balance; + @Schema(description = "Transaction history (e.g. disbursement).") + public List transactions; } @Schema(description = "Working capital loan running balances") @@ -244,6 +246,19 @@ private GetPaymentAllocation() {} public List paymentAllocationOrder; } + @Schema(description = "Loan transaction type enum data (same as basic loan)") + public static final class LoanTransactionEnumData { + + private LoanTransactionEnumData() {} + + @Schema(example = "1") + public Long id; + @Schema(example = "loanTransactionType.disbursement") + public String code; + @Schema(example = "Disbursement") + public String value; + } + @Schema(description = "GetPaymentAllocationOrder") public static final class GetPaymentAllocationOrder { @@ -260,9 +275,9 @@ public static final class PostWorkingCapitalLoansRequest { private PostWorkingCapitalLoansRequest() {} - @Schema(example = "1", required = true) + @Schema(example = "1", requiredMode = Schema.RequiredMode.REQUIRED) public Long clientId; - @Schema(example = "1", required = true) + @Schema(example = "1", requiredMode = Schema.RequiredMode.REQUIRED) public Long productId; @Schema(example = "1") public Long fundId; @@ -270,7 +285,7 @@ private PostWorkingCapitalLoansRequest() {} public String accountNo; @Schema(example = "ext-id-001") public String externalId; - @Schema(example = "10000.00", required = true, description = "Principal (disbursement) amount") + @Schema(example = "10000.00", requiredMode = Schema.RequiredMode.REQUIRED, description = "Principal (disbursement) amount") public BigDecimal principalAmount; @Schema(example = "10500.00") public BigDecimal totalPayment; @@ -335,6 +350,25 @@ private PostWorkingCapitalLoansResponse() {} public Long loanId; } + @Schema(description = "Payment details for disbursement (Account No, Cheque No, Routing Code, Receipt No, Bank code)") + public static final class PostWorkingCapitalLoansLoanIdDisbursementPaymentDetails { + + private PostWorkingCapitalLoansLoanIdDisbursementPaymentDetails() {} + + @Schema(example = "1", description = "Payment type id") + public Integer paymentTypeId; + @Schema(example = "acc123", description = "Account No") + public String accountNumber; + @Schema(example = "che123", description = "Cheque No") + public String checkNumber; + @Schema(example = "rou123", description = "Routing Code") + public String routingCode; + @Schema(example = "rec123", description = "Receipt No") + public String receiptNumber; + @Schema(example = "ban123", description = "Bank code") + public String bankNumber; + } + @Schema(description = "PutWorkingCapitalLoansLoanIdRequest") public static final class PutWorkingCapitalLoansLoanIdRequest { @@ -416,7 +450,7 @@ private PostWorkingCapitalLoansLoanIdResponse() {} public Object changes; } - @Schema(description = "PostWorkingCapitalLoansLoanIdRequest") + @Schema(description = "Request for state transition: approve, reject, undoapproval, disburse, undodisbursal") public static final class PostWorkingCapitalLoansLoanIdRequest { private PostWorkingCapitalLoansLoanIdRequest() {} @@ -431,11 +465,19 @@ private PostWorkingCapitalLoansLoanIdRequest() {} public BigDecimal discountAmount; @Schema(example = "15 January 2024", description = "Date of rejection") public String rejectedOnDate; - @Schema(example = "Approval/Rejection note") + @Schema(example = "Approval/Rejection/Disbursal Note") public String note; @Schema(example = "en_GB") public String locale; @Schema(example = "dd MMMM yyyy") public String dateFormat; + @Schema(example = "28 June 2024", description = "Required for disburse - Actual Disbursement date") + public String actualDisbursementDate; + @Schema(example = "1000", description = "Disbursement amount; required for disburse. Cannot exceed approved principal.") + public BigDecimal transactionAmount; + @Schema(example = "ext-disburse-001", description = "External ID; optional for disburse") + public String externalId; + @Schema(description = "Payment details (Account No, Cheque No, Routing Code, Receipt No, Bank code)") + public PostWorkingCapitalLoansLoanIdDisbursementPaymentDetails paymentDetails; } } 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 new file mode 100644 index 00000000000..7e574baa0ab --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java @@ -0,0 +1,140 @@ +/** + * 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.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.jersey.Pagination; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanTransactionReadPlatformService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@Path("/v1/working-capital-loans") +@Tag(name = "Working Capital Loan Transactions", description = "Retrieve Working Capital Loan transactions (e.g. disbursements).") +@RequiredArgsConstructor +public class WorkingCapitalLoanTransactionsApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = WorkingCapitalLoanConstants.WCL_RESOURCE_NAME; + + private final PlatformSecurityContext context; + private final WorkingCapitalLoanTransactionReadPlatformService transactionReadPlatformService; + + @GET + @Path("{loanId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionsById", summary = "Retrieve transactions", description = "Retrieves transactions of a Working Capital Loan.\n\nExample: working-capital-loans/1/transactions") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionsResponse.class))) }) + public Page retrieveTransactionsByLoanId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @Parameter(hidden = true) @Pagination final Pageable pageable) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransactions(loanId, pageable); + } + + @GET + @Path("external-id/{loanExternalId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionsByExternalId", summary = "Retrieve transactions by loan external id", description = "Retrieves transactions of a Working Capital Loan by loan external id.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionsResponse.class))) }) + public Page retrieveTransactionsByExternalLoanId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @Parameter(hidden = true) @Pagination final Pageable pageable) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransactions(ExternalIdFactory.produce(loanExternalId), pageable); + } + + @GET + @Path("{loanId}/transactions/{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionById", summary = "Retrieve a transaction", description = "Retrieves a single Working Capital Loan transaction.\n\nExample: working-capital-loans/1/transactions/1") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByLoanIdAndTransactionId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @PathParam("transactionId") @Parameter(description = "transactionId", required = true) final Long transactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(loanId, transactionId); + } + + @GET + @Path("{loanId}/transactions/external-id/{externalTransactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionByExternalTransactionId", summary = "Retrieve a transaction by external id", description = "Retrieves a single Working Capital Loan transaction by loan id and transaction external id.\n\nExample: working-capital-loans/1/transactions/external-id/txn-ext-001") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByLoanIdAndTransactionExternalId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @PathParam("externalTransactionId") @Parameter(description = "externalTransactionId", required = true) final String externalTransactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(loanId, ExternalIdFactory.produce(externalTransactionId)); + } + + @GET + @Path("external-id/{loanExternalId}/transactions/{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionByExternalLoanIdAndTransactionId", summary = "Retrieve a transaction by loan external id and transaction id", description = "Retrieves a single Working Capital Loan transaction by loan external id and transaction id.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByExternalLoanIdAndTransactionId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @PathParam("transactionId") @Parameter(description = "transactionId", required = true) final Long transactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(ExternalIdFactory.produce(loanExternalId), transactionId); + } + + @GET + @Path("external-id/{loanExternalId}/transactions/external-id/{externalTransactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionByExternalLoanIdAndExternalTransactionId", summary = "Retrieve a transaction by loan and transaction external ids", description = "Retrieves a single Working Capital Loan transaction by loan external id and transaction external id.\n\nExample: working-capital-loans/external-id/loan-ext-001/transactions/external-id/txn-ext-001") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByExternalLoanIdAndTransactionExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @PathParam("externalTransactionId") @Parameter(description = "externalTransactionId", required = true) final String externalTransactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(ExternalIdFactory.produce(loanExternalId), + ExternalIdFactory.produce(externalTransactionId)); + } +} 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 new file mode 100644 index 00000000000..1ec36f07450 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java @@ -0,0 +1,115 @@ +/** + * 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.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * Swagger documentation classes for Working Capital Loan Transactions API (GET list / GET one). + */ +public final class WorkingCapitalLoanTransactionsApiResourceSwagger { + + private WorkingCapitalLoanTransactionsApiResourceSwagger() {} + + @Schema(description = "GetWorkingCapitalLoanTransactionsResponse (Spring Data Page: content, totalElements, totalPages, number, size, first, last)") + public static final class GetWorkingCapitalLoanTransactionsResponse { + + private GetWorkingCapitalLoanTransactionsResponse() {} + + public List content; + @Schema(example = "5") + public Long totalElements; + @Schema(example = "1") + public Integer totalPages; + @Schema(example = "0") + public Integer number; + @Schema(example = "20") + public Integer size; + public Boolean first; + public Boolean last; + } + + @Schema(description = "Working Capital Loan transaction (e.g. disbursement) in GET transaction response.") + public static final class GetWorkingCapitalLoanTransactionIdResponse { + + private GetWorkingCapitalLoanTransactionIdResponse() {} + + @Schema(example = "1") + public Long id; + @Schema(description = "Transaction type") + public LoanTransactionEnumData type; + @Schema(example = "[2024, 2, 1]") + public LocalDate transactionDate; + @Schema(example = "[2024, 2, 1]") + public LocalDate submittedOnDate; + @Schema(example = "10000.00") + public BigDecimal transactionAmount; + @Schema(description = "Payment detail") + public WorkingCapitalLoanTransactionPaymentDetailData paymentDetailData; + @Schema(example = "txn-ext-001") + public String externalId; + @Schema(example = "false") + public Boolean reversed; + @Schema(example = "reversal-ext-001") + public String reversalExternalId; + @Schema(example = "[2024, 2, 5]") + public LocalDate reversedOnDate; + @Schema(example = "10000.00", description = "Principal portion from allocation") + public BigDecimal principalPortion; + @Schema(example = "0.00", description = "Fee charges portion from allocation") + public BigDecimal feeChargesPortion; + @Schema(example = "0.00", description = "Penalty charges portion from allocation") + public BigDecimal penaltyChargesPortion; + } + + @Schema(description = "Loan transaction type enum data (same as basic loan)") + public static final class LoanTransactionEnumData { + + private LoanTransactionEnumData() {} + + @Schema(example = "1") + public Long id; + @Schema(example = "loanTransactionType.disbursement") + public String code; + @Schema(example = "Disbursement") + public String value; + } + + @Schema(description = "Payment detail data") + public static final class WorkingCapitalLoanTransactionPaymentDetailData { + + private WorkingCapitalLoanTransactionPaymentDetailData() {} + + @Schema(example = "62") + public Long id; + @Schema(example = "acc123") + public String accountNumber; + @Schema(example = "che123") + public String checkNumber; + @Schema(example = "rou123") + public String routingCode; + @Schema(example = "rec123") + public String receiptNumber; + @Schema(example = "ban123") + public String bankNumber; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java index eb754cdf51d..b9f01e3c432 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java @@ -73,4 +73,5 @@ public class WorkingCapitalLoanData implements Serializable { private LoanApplicationTimelineData timeline; private List disbursementDetails; private WorkingCapitalLoanBalanceData balance; + private List transactions; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java new file mode 100644 index 00000000000..e52108ba42d --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java @@ -0,0 +1,54 @@ +/** + * 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.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkingCapitalLoanTransactionData implements Serializable { + + private Long id; + private LoanTransactionEnumData type; + private LocalDate transactionDate; + private LocalDate submittedOnDate; + private BigDecimal transactionAmount; + private ExternalId externalId; + private Boolean reversed; + private ExternalId reversalExternalId; + private LocalDate reversedOnDate; + + private WorkingCapitalLoanTransactionPaymentDetailData paymentDetailData; + // Portions from allocation (principal, fee, penalty). + private BigDecimal principalPortion; + private BigDecimal feeChargesPortion; + private BigDecimal penaltyChargesPortion; +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java new file mode 100644 index 00000000000..cc9c31e70d3 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java @@ -0,0 +1,41 @@ +/** + * 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.data; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkingCapitalLoanTransactionPaymentDetailData implements Serializable { + + private Long id; + private String accountNumber; + private String checkNumber; + private String routingCode; + private String receiptNumber; + private String bankNumber; +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java index 13420fc0b91..6330a88b2a2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -28,6 +28,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; +import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; @@ -162,6 +163,10 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom disbursementDetails = new ArrayList<>(); + @OrderBy(value = "dateOf, createdDate, id") + @OneToMany(cascade = CascadeType.ALL, mappedBy = "wcLoan", orphanRemoval = true, fetch = FetchType.LAZY) + private List transactions = new ArrayList<>(); + @Setter @Embedded private WorkingCapitalLoanProductRelatedDetails loanProductRelatedDetails; 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 index 58c398ab158..9d65e876a36 100644 --- 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 @@ -22,5 +22,7 @@ public enum WorkingCapitalLoanEvent { LOAN_APPROVED, // LOAN_APPROVAL_UNDO, // - LOAN_REJECTED // + LOAN_REJECTED, // + LOAN_DISBURSED, // + LOAN_DISBURSAL_UNDO // } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java index 0d7bae6488b..82d5d6d2ada 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java @@ -35,6 +35,10 @@ public void transition(final WorkingCapitalLoanEvent event, final WorkingCapital } } + public boolean canTransition(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) { + return getNextStatus(event, loan) != null; + } + private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) { LoanStatus from = loan.getLoanStatus(); if (from == null) { @@ -45,6 +49,8 @@ private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final Work 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; + case LOAN_DISBURSED -> from.isApproved() ? LoanStatus.ACTIVE : null; + case LOAN_DISBURSAL_UNDO -> from.isActive() ? LoanStatus.APPROVED : null; }; } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java new file mode 100644 index 00000000000..73d0c3231b1 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java @@ -0,0 +1,117 @@ +/** + * 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 jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionTypeConverter; + +@Entity +@Table(name = "m_wc_loan_transaction", uniqueConstraints = { + @UniqueConstraint(columnNames = { "external_id" }, name = "wc_loan_transaction_external_id_UNIQUE") }) +@Getter +public class WorkingCapitalLoanTransaction extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "wc_loan_id", nullable = false) + private WorkingCapitalLoan wcLoan; + + @Column(name = "transaction_type_id", nullable = false) + @Convert(converter = LoanTransactionTypeConverter.class) + private LoanTransactionType transactionType; + + @Column(name = "transaction_date", nullable = false) + private LocalDate dateOf; + + @Column(name = "submitted_on_date", nullable = false) + private LocalDate submittedOnDate; + + @Column(name = "transaction_amount", scale = 6, precision = 19, nullable = false) + private BigDecimal transactionAmount; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "payment_detail_id") + private WorkingCapitalLoanTransactionPaymentDetail paymentDetail; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "classification_cv_id") + private CodeValue classification; + + @Column(name = "external_id", length = 100, unique = true) + @Setter + private ExternalId externalId; + + @Column(name = "is_reversed", nullable = false) + @Setter + private boolean reversed; + + @Column(name = "reversal_external_id", length = 100, unique = true) + @Setter + private ExternalId reversalExternalId; + + @Column(name = "reversed_on_date") + @Setter + private LocalDate reversedOnDate; + + @Version + @Column(name = "version") + private Integer version; + + @OneToOne(mappedBy = "wcLoanTransaction", cascade = CascadeType.ALL, orphanRemoval = true) + private WorkingCapitalLoanTransactionAllocation allocation; + + protected WorkingCapitalLoanTransaction() {} + + public LoanTransactionType getTypeOf() { + return transactionType; + } + + public static WorkingCapitalLoanTransaction disbursement(final WorkingCapitalLoan loan, final BigDecimal amount, + final WorkingCapitalLoanTransactionPaymentDetail paymentDetail, final LocalDate disbursementDate, final ExternalId externalId) { + final WorkingCapitalLoanTransaction txn = new WorkingCapitalLoanTransaction(); + txn.wcLoan = loan; + txn.transactionType = LoanTransactionType.DISBURSEMENT; + txn.dateOf = disbursementDate; + txn.submittedOnDate = disbursementDate; + txn.transactionAmount = amount; + txn.paymentDetail = paymentDetail; + txn.externalId = externalId != null ? externalId : ExternalId.empty(); + txn.reversed = false; + txn.reversalExternalId = null; + txn.reversedOnDate = null; + return txn; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java new file mode 100644 index 00000000000..de117923a64 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java @@ -0,0 +1,71 @@ +/** + * 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 jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +@Entity +@Table(name = "m_wc_loan_transaction_allocation", uniqueConstraints = { + @UniqueConstraint(columnNames = { "wc_loan_transaction_id" }, name = "uq_m_wc_loan_transaction_allocation_transaction_id") }) +@Getter +public class WorkingCapitalLoanTransactionAllocation extends AbstractAuditableWithUTCDateTimeCustom { + + @OneToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "wc_loan_transaction_id", nullable = false, unique = true) + private WorkingCapitalLoanTransaction wcLoanTransaction; + + @Column(name = "principal_portion_derived", scale = 6, precision = 19) + @Setter + private BigDecimal principalPortion; + + @Column(name = "fee_charges_portion_derived", scale = 6, precision = 19) + @Setter + private BigDecimal feeChargesPortion; + + @Column(name = "penalty_charges_portion_derived", scale = 6, precision = 19) + @Setter + private BigDecimal penaltyChargesPortion; + + @Version + @Column(name = "version") + private Integer version; + + protected WorkingCapitalLoanTransactionAllocation() {} + + public static WorkingCapitalLoanTransactionAllocation forDisbursement(final WorkingCapitalLoanTransaction transaction, + final BigDecimal principalAmount) { + final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation(); + allocation.wcLoanTransaction = transaction; + allocation.principalPortion = principalAmount != null ? principalAmount : BigDecimal.ZERO; + allocation.feeChargesPortion = BigDecimal.ZERO; + allocation.penaltyChargesPortion = BigDecimal.ZERO; + return allocation; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java new file mode 100644 index 00000000000..99a66ab5d2e --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java @@ -0,0 +1,59 @@ +/** + * 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 jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_wc_loan_transaction_payment_detail") +@Getter +public class WorkingCapitalLoanTransactionPaymentDetail extends AbstractPersistableCustom { + + @Column(name = "account_number", length = 50) + private String accountNumber; + + @Column(name = "check_number", length = 50) + private String checkNumber; + + @Column(name = "routing_code", length = 50) + private String routingCode; + + @Column(name = "receipt_number", length = 50) + private String receiptNumber; + + @Column(name = "bank_number", length = 50) + private String bankNumber; + + protected WorkingCapitalLoanTransactionPaymentDetail() {} + + public static WorkingCapitalLoanTransactionPaymentDetail of(final String accountNumber, final String checkNumber, + final String routingCode, final String receiptNumber, final String bankNumber) { + final WorkingCapitalLoanTransactionPaymentDetail d = new WorkingCapitalLoanTransactionPaymentDetail(); + d.accountNumber = accountNumber; + d.checkNumber = checkNumber; + d.routingCode = routingCode; + d.receiptNumber = receiptNumber; + d.bankNumber = bankNumber; + return d; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java new file mode 100644 index 00000000000..070715bd88a --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java @@ -0,0 +1,39 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.exception; + +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +/** + * Thrown when a Working Capital Loan transaction is not found. + */ +public class WorkingCapitalLoanTransactionNotFoundException extends AbstractPlatformResourceNotFoundException { + + public WorkingCapitalLoanTransactionNotFoundException(final Long transactionId, final Long loanId) { + super("error.msg.wc.loan.transaction.not.found", "Working Capital Loan transaction with identifier " + transactionId + + " does not exist for loan with identifier " + loanId + ".", transactionId, loanId); + } + + public WorkingCapitalLoanTransactionNotFoundException(final ExternalId transactionExternalId) { + super("error.msg.wc.loan.transaction.not.found", + "Working Capital Loan transaction with external identifier " + transactionExternalId.getValue() + " does not exist", + transactionExternalId.getValue()); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java new file mode 100644 index 00000000000..015794b9489 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.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 = "DISBURSE") +public class DisburseWorkingCapitalLoanCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.disburseLoan(command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java new file mode 100644 index 00000000000..9ac28d5d8d3 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.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 = "DISBURSALUNDO") +public class UndoDisburseWorkingCapitalLoanCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.undoDisbursal(command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java index c30aa67cc80..97b260cc48b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java @@ -46,7 +46,8 @@ import org.mapstruct.factory.Mappers; @Mapper(config = MapstructMapperConfig.class, uses = { DelinquencyBucketMapper.class, WorkingCapitalLoanProductMapper.class, - WorkingCapitalLoanBalanceMapper.class, WorkingCapitalLoanDisbursementDetailMapper.class }) + WorkingCapitalLoanBalanceMapper.class, WorkingCapitalLoanDisbursementDetailMapper.class, + WorkingCapitalLoanTransactionMapper.class }) public interface WorkingCapitalLoanMapper { @Mapping(target = "accountNo", source = "accountNumber") @@ -66,6 +67,7 @@ public interface WorkingCapitalLoanMapper { @Mapping(target = "paymentAllocation", source = "paymentAllocationRules", qualifiedByName = "paymentAllocationRulesToData") @Mapping(target = "timeline", source = "loan", qualifiedByName = "timelineData") @Mapping(target = "disbursementDetails", source = "disbursementDetails") + @Mapping(target = "transactions", source = "transactions") WorkingCapitalLoanData toData(WorkingCapitalLoan loan); List toDataList(List loans); @@ -120,6 +122,8 @@ default LoanApplicationTimelineData timelineData(final WorkingCapitalLoan loan) : loan.getDisbursementDetails().getFirst().getExpectedDisbursementDate(); timelineData.setExpectedDisbursementDate(expectedDisbursementDate); timelineData.setSubmittedOnDate(loan.getSubmittedOnDate()); + timelineData.setExpectedMaturityDate(loan.getExpectedMaturityDate()); + timelineData.setActualMaturityDate(loan.getMaturedOnDate()); if (loan.getApprovedBy() != null) { timelineData.setApprovedByUsername(loan.getApprovedBy().getUsername()); timelineData.setApprovedByFirstname(loan.getApprovedBy().getFirstname()); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java new file mode 100644 index 00000000000..d5017da9bd9 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java @@ -0,0 +1,60 @@ +/** + * 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.mapper; + +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionPaymentDetailData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +@Mapper(config = MapstructMapperConfig.class) +public interface WorkingCapitalLoanTransactionMapper { + + @Mapping(target = "type", source = "transactionType", qualifiedByName = "loanTransactionTypeToEnumData") + @Mapping(target = "paymentDetailData", source = "paymentDetail", qualifiedByName = "paymentDetailToData") + @Mapping(target = "transactionDate", source = "dateOf") + @Mapping(target = "principalPortion", source = "allocation.principalPortion") + @Mapping(target = "feeChargesPortion", source = "allocation.feeChargesPortion") + @Mapping(target = "penaltyChargesPortion", source = "allocation.penaltyChargesPortion") + WorkingCapitalLoanTransactionData toData(WorkingCapitalLoanTransaction transaction); + + @Named("loanTransactionTypeToEnumData") + default LoanTransactionEnumData loanTransactionTypeToEnumData(final LoanTransactionType type) { + return type == null ? null : LoanEnumerations.transactionType(type); + } + + @Named("paymentDetailToData") + default WorkingCapitalLoanTransactionPaymentDetailData paymentDetailToData( + final WorkingCapitalLoanTransactionPaymentDetail paymentDetail) { + if (paymentDetail == null) { + return null; + } + return WorkingCapitalLoanTransactionPaymentDetailData.builder().id(paymentDetail.getId()) + .accountNumber(paymentDetail.getAccountNumber()).checkNumber(paymentDetail.getCheckNumber()) + .routingCode(paymentDetail.getRoutingCode()).receiptNumber(paymentDetail.getReceiptNumber()) + .bankNumber(paymentDetail.getBankNumber()).build(); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java new file mode 100644 index 00000000000..cf5e11bd301 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java @@ -0,0 +1,28 @@ +/** + * 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.repository; + +import java.util.Optional; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanBalanceRepository extends JpaRepository { + + Optional findByWcLoan_Id(Long wcLoanId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java index 9b68d08bfdd..d8056bb6d0e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java @@ -48,6 +48,8 @@ public interface WorkingCapitalLoanRepository extends JpaRepository findByExternalIdWithDetails(@Param("externalId") ExternalId externalId); @@ -70,6 +74,8 @@ public interface WorkingCapitalLoanRepository extends JpaRepository findByIdInWithFullDetails(@Param("ids") List ids); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java new file mode 100644 index 00000000000..ee2d1370a16 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java @@ -0,0 +1,24 @@ +/** + * 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.repository; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository {} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java new file mode 100644 index 00000000000..0e37197bdb3 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java @@ -0,0 +1,25 @@ +/** + * 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.repository; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanTransactionPaymentDetailRepository + extends JpaRepository {} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java new file mode 100644 index 00000000000..7429dd17246 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -0,0 +1,40 @@ +/** + * 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.repository; + +import java.util.List; +import java.util.Optional; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanTransactionRepository extends JpaRepository { + + List findByWcLoan_IdOrderByDateOfAscIdAsc(Long wcLoanId); + + Page findByWcLoan_IdOrderByDateOfAscIdAsc(Long wcLoanId, Pageable pageable); + + Optional findByIdAndWcLoan_Id(Long id, Long wcLoanId); + + Optional findByWcLoan_IdAndExternalId(Long wcLoanId, ExternalId externalId); + + boolean existsByExternalId(ExternalId externalId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index ea926f23e50..f2a88a1cd52 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 @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.workingcapitalloan.serialization; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.math.BigDecimal; @@ -33,12 +34,17 @@ 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.domain.ExternalId; 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.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; +import org.apache.fineract.portfolio.loanaccount.domain.ExpectedDisbursementDateValidator; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.springframework.stereotype.Component; @Component @@ -46,6 +52,8 @@ public class WorkingCapitalLoanDataValidator { private final FromJsonHelper fromApiJsonHelper; + private final ExpectedDisbursementDateValidator expectedDisbursementDateValidator; + private final WorkingCapitalLoanTransactionRepository transactionRepository; // Per requirement: only principal, discount, approved date, expected disbursement date, and notes private static final Set APPROVAL_SUPPORTED_PARAMETERS = new HashSet<>( @@ -59,6 +67,23 @@ public class WorkingCapitalLoanDataValidator { private static final Set UNDO_APPROVAL_SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.noteParamName)); + private static final Set DISBURSAL_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("locale", "dateFormat", + WorkingCapitalLoanConstants.actualDisbursementDateParamName, WorkingCapitalLoanConstants.transactionAmountParamName, + WorkingCapitalLoanConstants.discountAmountParamName, WorkingCapitalLoanConstants.noteParamName, + WorkingCapitalLoanConstants.paymentDetailsParamName, WorkingCapitalLoanConstants.externalIdParameterName)); + + private static final Set PAYMENT_DETAILS_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList(WorkingCapitalLoanConstants.paymentTypeIdParamName, WorkingCapitalLoanConstants.accountNumberParamName, + WorkingCapitalLoanConstants.checkNumberParamName, WorkingCapitalLoanConstants.routingCodeParamName, + WorkingCapitalLoanConstants.receiptNumberParamName, WorkingCapitalLoanConstants.bankNumberParamName)); + + private static final Set UNDO_DISBURSAL_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.noteParamName)); + + private static final int NOTE_MAX_LENGTH = 1000; + private static final int EXTERNAL_ID_MAX_LENGTH = 100; + private static final int PAYMENT_DETAIL_STRING_MAX_LENGTH = 50; + public void validateApproval(final String json, final WorkingCapitalLoan loan) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); @@ -175,6 +200,157 @@ public void validateUndoApproval(final String json) { this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UNDO_APPROVAL_SUPPORTED_PARAMETERS); } + public void validateDisbursement(final String json, final WorkingCapitalLoan loan) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DISBURSAL_SUPPORTED_PARAMETERS); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + if (element != null && element.isJsonObject()) { + final JsonObject root = element.getAsJsonObject(); + if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) + && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { + final String paymentDetailsJson = root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName).toString(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, paymentDetailsJson, PAYMENT_DETAILS_SUPPORTED_PARAMETERS); + } + } + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + + final LocalDate actualDisbursementDate = this.fromApiJsonHelper + .extractLocalDateNamed(WorkingCapitalLoanConstants.actualDisbursementDateParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName).value(actualDisbursementDate) + .notNull(); + + if (actualDisbursementDate != null) { + if (DateUtils.isDateInTheFuture(actualDisbursementDate)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName) + .failWithCode("cannot.be.a.future.date"); + } + + if (loan.getSubmittedOnDate() != null && DateUtils.isBefore(actualDisbursementDate, loan.getSubmittedOnDate())) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName) + .failWithCode("cannot.be.before.submitted.date"); + } + + if (loan.getApprovedOnDate() != null && DateUtils.isBefore(actualDisbursementDate, loan.getApprovedOnDate())) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName) + .failWithCode("cannot.be.before.approval.date"); + } + } + + // Align with Loan: disbursement not allowed when client is not active + if (loan.getClient() != null && loan.getClient().isNotActive()) { + throw new ClientNotActiveException(loan.getClient().getId()); + } + + // Align with Loan and WCL application: actual disbursement date not on non-working day or holiday when + // disallowed + if (actualDisbursementDate != null && loan.getOfficeId() != null) { + this.expectedDisbursementDateValidator.validate(actualDisbursementDate, loan.getOfficeId()); + } + + final BigDecimal transactionAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionAmountParamName).value(transactionAmount).notNull() + .positiveAmount(); + if (transactionAmount != null && loan.getApprovedPrincipal() != null + && transactionAmount.compareTo(loan.getApprovedPrincipal()) > 0) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionAmountParamName) + .failWithCode("amount.cannot.exceed.approved.principal"); + } + + 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"); + } + } + + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.externalIdParameterName, element)) { + final String externalIdStr = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.externalIdParameterName, + element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.externalIdParameterName).value(externalIdStr).ignoreIfNull() + .notExceedingLengthOf(EXTERNAL_ID_MAX_LENGTH); + if (externalIdStr != null && !externalIdStr.isBlank()) { + final ExternalId externalId = ExternalIdFactory.produce(externalIdStr); + if (!externalId.isEmpty() && this.transactionRepository.existsByExternalId(externalId)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.externalIdParameterName).failWithCode("already.exists"); + } + } + } + + validateDisbursementPaymentDetails(baseDataValidator, element); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + /** + * Validates payment details inside paymentDetails object: paymentTypeId integerGreaterThanZero when present; + * accountNumber, checkNumber, routingCode, receiptNumber, bankNumber notExceedingLengthOf(50) when present. + */ + private void validateDisbursementPaymentDetails(final DataValidatorBuilder baseDataValidator, final JsonElement element) { + final JsonElement paymentDetailsElement = resolvePaymentDetailsElement(element); + final Integer paymentTypeId = this.fromApiJsonHelper + .extractIntegerSansLocaleNamed(WorkingCapitalLoanConstants.paymentTypeIdParamName, paymentDetailsElement); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull() + .integerGreaterThanZero(); + + for (final String paramName : Arrays.asList(WorkingCapitalLoanConstants.accountNumberParamName, + WorkingCapitalLoanConstants.checkNumberParamName, WorkingCapitalLoanConstants.routingCodeParamName, + WorkingCapitalLoanConstants.receiptNumberParamName, WorkingCapitalLoanConstants.bankNumberParamName)) { + final String value = this.fromApiJsonHelper.extractStringNamed(paramName, paymentDetailsElement); + baseDataValidator.reset().parameter(paramName).value(value).ignoreIfNull() + .notExceedingLengthOf(PAYMENT_DETAIL_STRING_MAX_LENGTH); + } + } + + private JsonElement resolvePaymentDetailsElement(final JsonElement element) { + if (element != null && element.isJsonObject()) { + final JsonObject root = element.getAsJsonObject(); + if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) + && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { + return root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName); + } + } + return element; + } + + public void validateUndoDisbursal(final String json) { + if (StringUtils.isBlank(json)) { + return; + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UNDO_DISBURSAL_SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + final JsonElement element = this.fromApiJsonHelper.parse(json); + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java new file mode 100644 index 00000000000..db8fbef2bf1 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java @@ -0,0 +1,57 @@ +/** + * 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.domain.ExternalId; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface WorkingCapitalLoanTransactionReadPlatformService { + + /** + * Retrieves paginated transactions of a Working Capital Loan by loan id. + */ + Page retrieveTransactions(Long loanId, Pageable pageable); + + /** + * Retrieves paginated transactions of a Working Capital Loan by loan external id. + */ + Page retrieveTransactions(ExternalId loanExternalId, Pageable pageable); + + /** + * Retrieves a single Working Capital Loan transaction by loan id and transaction id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(Long loanId, Long transactionId); + + /** + * Retrieves a single Working Capital Loan transaction by loan external id and transaction id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(ExternalId loanExternalId, Long transactionId); + + /** + * Retrieves a single Working Capital Loan transaction by loan id and transaction external id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(Long loanId, ExternalId transactionExternalId); + + /** + * Retrieves a single Working Capital Loan transaction by loan external id and transaction external id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(ExternalId loanExternalId, ExternalId transactionExternalId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java new file mode 100644 index 00000000000..aa0f5e511c9 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java @@ -0,0 +1,107 @@ +/** + * 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.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanTransactionNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanTransactionMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WorkingCapitalLoanTransactionReadPlatformServiceImpl implements WorkingCapitalLoanTransactionReadPlatformService { + + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanRepository workingCapitalLoanRepository; + private final WorkingCapitalLoanTransactionMapper transactionMapper; + + @Override + public Page retrieveTransactions(final Long loanId, final Pageable pageable) { + ensureLoanExists(loanId); + final Page page = this.transactionRepository.findByWcLoan_IdOrderByDateOfAscIdAsc(loanId, pageable); + final List content = page.getContent().stream().map(this.transactionMapper::toData).toList(); + return new PageImpl<>(content, page.getPageable(), page.getTotalElements()); + } + + @Override + public Page retrieveTransactions(final ExternalId loanExternalId, final Pageable pageable) { + final Long loanId = getResolvedLoanId(loanExternalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExternalId); + } + return retrieveTransactions(loanId, pageable); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final Long loanId, final Long transactionId) { + ensureLoanExists(loanId); + final WorkingCapitalLoanTransaction txn = this.transactionRepository.findByIdAndWcLoan_Id(transactionId, loanId) + .orElseThrow(() -> new WorkingCapitalLoanTransactionNotFoundException(transactionId, loanId)); + return this.transactionMapper.toData(txn); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final ExternalId loanExternalId, final Long transactionId) { + final Long loanId = getResolvedLoanId(loanExternalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExternalId); + } + return retrieveTransaction(loanId, transactionId); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final Long loanId, final ExternalId transactionExternalId) { + ensureLoanExists(loanId); + final WorkingCapitalLoanTransaction txn = this.transactionRepository.findByWcLoan_IdAndExternalId(loanId, transactionExternalId) + .orElseThrow(() -> new WorkingCapitalLoanTransactionNotFoundException(transactionExternalId)); + return this.transactionMapper.toData(txn); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final ExternalId loanExternalId, final ExternalId transactionExternalId) { + final Long loanId = getResolvedLoanId(loanExternalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExternalId); + } + return retrieveTransaction(loanId, transactionExternalId); + } + + private Long getResolvedLoanId(final ExternalId externalId) { + return this.workingCapitalLoanRepository.findByExternalId(externalId).map(WorkingCapitalLoan::getId).orElse(null); + } + + private void ensureLoanExists(final Long loanId) { + if (!this.workingCapitalLoanRepository.existsById(loanId)) { + throw new WorkingCapitalLoanNotFoundException(loanId); + } + } +} 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 5b22ea9a73e..3571493272a 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 @@ -28,4 +28,8 @@ public interface WorkingCapitalLoanWritePlatformService { CommandProcessingResult undoApplicationApproval(Long loanId, JsonCommand command); CommandProcessingResult rejectApplication(Long loanId, JsonCommand command); + + CommandProcessingResult disburseLoan(Long loanId, JsonCommand command); + + CommandProcessingResult undoDisbursal(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 1f7e975fe38..bdea797e342 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 @@ -18,32 +18,51 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import com.google.gson.JsonElement; import java.math.BigDecimal; import java.time.LocalDate; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; 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.domain.ExternalId; +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.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionPaymentDetailRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; 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; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -56,6 +75,11 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanLifecycleStateMachine stateMachine; private final FromJsonHelper fromApiJsonHelper; private final WorkingCapitalLoanNoteRepository noteRepository; + private final ExternalIdFactory externalIdFactory; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; + private final WorkingCapitalLoanTransactionPaymentDetailRepository paymentDetailRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -181,6 +205,195 @@ public CommandProcessingResult rejectApplication(final Long loanId, final JsonCo .withLoanId(loanId).with(changes).build(); } + @Transactional + @Override + public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + this.validator.validateDisbursement(command.json(), loan); + + final AppUser currentUser = this.context.getAuthenticatedUserIfPresent(); + + if (!this.stateMachine.canTransition(WorkingCapitalLoanEvent.LOAN_DISBURSED, loan)) { + throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", + "Disbursement is not allowed from current status " + loan.getLoanStatus(), "loanStatus"); + } + + final LocalDate actualDisbursementDate = command + .localDateValueOfParameterNamed(WorkingCapitalLoanConstants.actualDisbursementDateParamName); + final BigDecimal transactionAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, command.parsedJson(), new HashSet<>()); + + final Map changes = new LinkedHashMap<>(); + final WorkingCapitalLoanTransactionPaymentDetail paymentDetail = createAndPersistPaymentDetailFromCommand(command, changes); + + this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSED, loan); + + if (!loan.getDisbursementDetails().isEmpty()) { + loan.getDisbursementDetails().getFirst().setActualDisbursementDate(actualDisbursementDate); + loan.getDisbursementDetails().getFirst().setActualAmount(transactionAmount); + loan.getDisbursementDetails().getFirst().setDisbursedBy(currentUser); + } + + if (command.parameterExists(WorkingCapitalLoanConstants.discountAmountParamName)) { + final BigDecimal discount = this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.discountAmountParamName, + command.parsedJson(), new HashSet<>()); + if (discount != null) { + loan.getLoanProductRelatedDetails().setDiscount(discount); + } + } + + final ExternalId txnExternalId = this.externalIdFactory.createFromCommand(command, + WorkingCapitalLoanConstants.externalIdParameterName); + final WorkingCapitalLoanTransaction disbursementTransaction = WorkingCapitalLoanTransaction.disbursement(loan, transactionAmount, + paymentDetail, actualDisbursementDate, txnExternalId); + this.transactionRepository.saveAndFlush(disbursementTransaction); + + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation + .forDisbursement(disbursementTransaction, transactionAmount); + this.allocationRepository.saveAndFlush(allocation); + + updateBalanceOnDisburse(loan, transactionAmount); + + this.loanRepository.saveAndFlush(loan); + + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + createNote(noteText, loan); + + log.debug("Working capital loan {} disbursed by user {}", loanId, currentUser != null ? currentUser.getId() : "system"); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withSubEntityId(disbursementTransaction.getId()) // + .withSubEntityExternalId(disbursementTransaction.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); + } + + @Override + public CommandProcessingResult undoDisbursal(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + this.validator.validateUndoDisbursal(command.json()); + + if (loan.getClient() != null && loan.getClient().isNotActive()) { + throw new ClientNotActiveException(loan.getClient().getId()); + } + + this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSAL_UNDO, loan); + + reverseDisbursementTransactionsAndResetBalance(loan); + + if (loan.getDisbursementDetails() != null) { + for (WorkingCapitalLoanDisbursementDetails detail : loan.getDisbursementDetails()) { + if (detail.getActualDisbursementDate() != null) { + detail.setActualDisbursementDate(null); + detail.setActualAmount(null); + detail.setDisbursedBy(null); + } + } + } + + this.loanRepository.saveAndFlush(loan); + + final Map changes = new LinkedHashMap<>(); + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + createNote(noteText, loan); + + log.debug("Working capital loan {} disbursal undone", loanId); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) + .withEntityExternalId(loan.getExternalId()).withLoanId(loanId).with(changes).build(); + } + + private WorkingCapitalLoanTransactionPaymentDetail createAndPersistPaymentDetailFromCommand(final JsonCommand command, + final Map changes) { + final JsonElement paymentDetailsElement = command.jsonElement(WorkingCapitalLoanConstants.paymentDetailsParamName); + if (paymentDetailsElement != null && paymentDetailsElement.isJsonObject()) { + final JsonCommand paymentDetailsCommand = JsonCommand.fromExistingCommand(command, paymentDetailsElement); + return createAndPersistWclPaymentDetail(paymentDetailsCommand, changes); + } + return createAndPersistWclPaymentDetail(command, changes); + } + + private WorkingCapitalLoanTransactionPaymentDetail createAndPersistWclPaymentDetail(final JsonCommand paymentDetailsCommand, + final Map changes) { + final String accountNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.accountNumberParamName); + final String checkNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.checkNumberParamName); + final String routingCode = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.routingCodeParamName); + final String receiptNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.receiptNumberParamName); + final String bankNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.bankNumberParamName); + + final boolean hasAny = StringUtils.isNotBlank(accountNumber) || StringUtils.isNotBlank(checkNumber) + || StringUtils.isNotBlank(routingCode) || StringUtils.isNotBlank(receiptNumber) || StringUtils.isNotBlank(bankNumber); + if (!hasAny) { + return null; + } + + if (StringUtils.isNotBlank(accountNumber)) { + changes.put(WorkingCapitalLoanConstants.accountNumberParamName, accountNumber); + } + if (StringUtils.isNotBlank(checkNumber)) { + changes.put(WorkingCapitalLoanConstants.checkNumberParamName, checkNumber); + } + if (StringUtils.isNotBlank(routingCode)) { + changes.put(WorkingCapitalLoanConstants.routingCodeParamName, routingCode); + } + if (StringUtils.isNotBlank(receiptNumber)) { + changes.put(WorkingCapitalLoanConstants.receiptNumberParamName, receiptNumber); + } + if (StringUtils.isNotBlank(bankNumber)) { + changes.put(WorkingCapitalLoanConstants.bankNumberParamName, bankNumber); + } + + final WorkingCapitalLoanTransactionPaymentDetail detail = WorkingCapitalLoanTransactionPaymentDetail.of(accountNumber, checkNumber, + routingCode, receiptNumber, bankNumber); + return this.paymentDetailRepository.saveAndFlush(detail); + } + + private void updateBalanceOnDisburse(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount) { + WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()).orElse(null); + if (balance == null) { + balance = WorkingCapitalLoanBalance.createFor(loan); + } + balance.setPrincipalOutstanding(disbursedAmount); + this.balanceRepository.saveAndFlush(balance); + } + + private void reverseDisbursementTransactionsAndResetBalance(final WorkingCapitalLoan loan) { + final List transactions = this.transactionRepository + .findByWcLoan_IdOrderByDateOfAscIdAsc(loan.getId()); + for (WorkingCapitalLoanTransaction txn : transactions) { + if (txn.getTypeOf() == LoanTransactionType.DISBURSEMENT && !txn.isReversed()) { + txn.setReversed(true); + txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); + txn.setReversalExternalId(ExternalId.generate()); + this.transactionRepository.save(txn); + } + } + this.transactionRepository.flush(); + + final Optional balanceOpt = this.balanceRepository.findByWcLoan_Id(loan.getId()); + balanceOpt.ifPresent(b -> { + b.setPrincipalOutstanding(BigDecimal.ZERO); + b.setTotalPaidPrincipal(BigDecimal.ZERO); + this.balanceRepository.saveAndFlush(b); + }); + } + private void createNote(final String noteText, final WorkingCapitalLoan loan) { if (StringUtils.isNotBlank(noteText)) { final WorkingCapitalLoanNote note = WorkingCapitalLoanNote.create(loan, noteText); 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 cb5a7f366cc..47b03fb8cf8 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 @@ -30,4 +30,6 @@ + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_wc_loan_transaction.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_wc_loan_transaction.xml new file mode 100644 index 00000000000..d8568d79ebd --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0008_wc_loan_transaction.xml @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0009_wc_loan_disbursement_permissions.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0009_wc_loan_disbursement_permissions.xml new file mode 100644 index 00000000000..fa2aa527d56 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0009_wc_loan_disbursement_permissions.xml @@ -0,0 +1,56 @@ + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DISBURSE_WORKINGCAPITALLOAN'; + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DISBURSALUNDO_WORKINGCAPITALLOAN'; + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java new file mode 100644 index 00000000000..3b16be53c08 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java @@ -0,0 +1,106 @@ +/** + * 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.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WorkingCapitalLoanTransactionMapperTest { + + private final WorkingCapitalLoanTransactionMapper mapper = Mappers.getMapper(WorkingCapitalLoanTransactionMapper.class); + + @Mock + private WorkingCapitalLoanTransaction transaction; + + @Mock + private WorkingCapitalLoanTransactionAllocation allocation; + + @Test + void toData_mapsAllFieldsIncludingAllocationPortions() { + final LocalDate txnDate = LocalDate.of(2024, 2, 1); + final BigDecimal amount = BigDecimal.valueOf(10000); + when(transaction.getId()).thenReturn(1L); + when(transaction.getTransactionType()).thenReturn(LoanTransactionType.DISBURSEMENT); + when(transaction.getDateOf()).thenReturn(txnDate); + when(transaction.getSubmittedOnDate()).thenReturn(txnDate); + when(transaction.getTransactionAmount()).thenReturn(amount); + when(transaction.getExternalId()).thenReturn(new ExternalId("ext-1")); + when(transaction.isReversed()).thenReturn(false); + when(transaction.getReversalExternalId()).thenReturn(null); + when(transaction.getReversedOnDate()).thenReturn(null); + when(transaction.getAllocation()).thenReturn(allocation); + when(allocation.getPrincipalPortion()).thenReturn(amount); + when(allocation.getFeeChargesPortion()).thenReturn(null); + when(allocation.getPenaltyChargesPortion()).thenReturn(null); + + final WorkingCapitalLoanTransactionData data = mapper.toData(transaction); + + assertNotNull(data); + assertEquals(1L, data.getId()); + assertNotNull(data.getType()); + assertEquals(LoanTransactionType.DISBURSEMENT.getValue().longValue(), data.getType().getId()); + assertEquals(LoanTransactionType.DISBURSEMENT.getCode(), data.getType().getCode()); + assertEquals(txnDate, data.getTransactionDate()); + assertEquals(txnDate, data.getSubmittedOnDate()); + assertEquals(amount, data.getTransactionAmount()); + assertEquals(amount, data.getPrincipalPortion()); + assertNull(data.getFeeChargesPortion()); + assertNull(data.getPenaltyChargesPortion()); + assertEquals(false, data.getReversed()); + } + + @Test + void toData_whenAllocationNull_setsPortionsToNull() { + when(transaction.getId()).thenReturn(2L); + when(transaction.getTransactionType()).thenReturn(LoanTransactionType.DISBURSEMENT); + when(transaction.getDateOf()).thenReturn(LocalDate.of(2024, 2, 1)); + when(transaction.getSubmittedOnDate()).thenReturn(LocalDate.of(2024, 2, 1)); + when(transaction.getTransactionAmount()).thenReturn(BigDecimal.valueOf(5000)); + when(transaction.getExternalId()).thenReturn(null); + when(transaction.isReversed()).thenReturn(false); + when(transaction.getReversalExternalId()).thenReturn(null); + when(transaction.getReversedOnDate()).thenReturn(null); + when(transaction.getAllocation()).thenReturn(null); + + final WorkingCapitalLoanTransactionData data = mapper.toData(transaction); + + assertNotNull(data); + assertNotNull(data.getType()); + assertEquals(LoanTransactionType.DISBURSEMENT.getCode(), data.getType().getCode()); + assertNull(data.getPrincipalPortion()); + assertNull(data.getFeeChargesPortion()); + assertNull(data.getPenaltyChargesPortion()); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java new file mode 100644 index 00000000000..49cd95d9c53 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java @@ -0,0 +1,1216 @@ +/** + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.restassured.RestAssured; +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.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +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.workingcapitalloan.WorkingCapitalLoanApplicationHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class WorkingCapitalLoanDisbursementTest { + + private static RequestSpecification requestSpec; + private static ResponseSpecification responseSpec; + + private final WorkingCapitalLoanApplicationHelper applicationHelper = new WorkingCapitalLoanApplicationHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + private final Long createdClientId = createClient(); + + private static final String CLEANUP_EMPTY_COMMAND_JSON = "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\"}"; + + @BeforeAll + static void setup() { + 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(); + } + + @AfterEach + void cleanupEntities() { + // Loans: undo disbursal -> undo approval -> delete + for (final Long loanId : createdLoanIds) { + if (loanId == null) { + continue; + } + try { + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be disbursed / client inactive / loan already removed) + } + try { + applicationHelper.undoApprovalById(loanId, CLEANUP_EMPTY_COMMAND_JSON); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be approved / already removed) + } + try { + applicationHelper.deleteById(loanId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may be in non-deletable state / already removed) + } + } + createdLoanIds.clear(); + + // Products + for (final Long productId : createdProductIds) { + if (productId == null) { + continue; + } + try { + productHelper.deleteWorkingCapitalLoanProductById(productId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (product may be already removed) + } + } + createdProductIds.clear(); + } + + @Test + public void testDisburseWorkingCapitalLoan() { + final Long productId = createProduct(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, + BigDecimal.valueOf(5000)); + applicationHelper.disburseById(loanId, disburseJson); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.active"); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after disburse should include balance"); + final JsonObject balance = data.getAsJsonObject("balance"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), balance.get("principalOutstanding")); + + assertTrue(data.has("disbursementDetails") && data.get("disbursementDetails").isJsonArray(), + "GET loan after disburse should include disbursementDetails array"); + assertFalse(data.getAsJsonArray("disbursementDetails").isEmpty(), "disbursementDetails should not be empty"); + final JsonObject disbursement = data.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + assertTrue(disbursement.has("actualDisbursementDate")); + assertDateEquals(actualDisbursementDate, disbursement.get("actualDisbursementDate")); + assertTrue(disbursement.has("actualAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(5000), disbursement.get("actualAmount")); + + assertTrue(data.has("transactions"), "GET loan after disburse should include transactions"); + assertTrue(data.get("transactions").isJsonArray()); + assertEquals(1, data.getAsJsonArray("transactions").size(), "After disburse there should be one transaction"); + final JsonObject txn = data.getAsJsonArray("transactions").get(0).getAsJsonObject(); + assertTrue(txn.has("type") && txn.has("transactionAmount")); + assertEquals("loanTransactionType.disbursement", txn.getAsJsonObject("type").get("code").getAsString()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("transactionAmount")); + assertTrue(txn.has("reversed") && !txn.get("reversed").getAsBoolean(), "Disbursement transaction should not be reversed"); + assertTrue(txn.has("principalPortion"), "Transaction should include allocation principalPortion"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("principalPortion")); + assertTrue(txn.has("feeChargesPortion"), "Transaction should include allocation feeChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion"), "Transaction should include allocation penaltyChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + } + + @Test + public void testDisburseWithAllRequestFieldsAndVerifyResponse() { + final Long productId = createProductWithDiscountAllowed(); + + final BigDecimal approvedPrincipal = BigDecimal.valueOf(10000); + final BigDecimal approvedDiscount = BigDecimal.valueOf(50); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(approvedPrincipal) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(11000)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, approvedPrincipal, approvedDiscount)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + final BigDecimal transactionAmount = BigDecimal.valueOf(8000); + final BigDecimal discountAmount = BigDecimal.valueOf(30); + final String note = "Disbursal note for test"; + final Integer paymentTypeId = 1; + final String accountNumber = "acc-" + UUID.randomUUID().toString().substring(0, 8); + final String checkNumber = "chk-123"; + final String routingCode = "rte-456"; + final String receiptNumber = "rec-789"; + final String bankNumber = "bnk-001"; + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, transactionAmount, + discountAmount, note, paymentTypeId, accountNumber, checkNumber, routingCode, receiptNumber, bankNumber); + applicationHelper.disburseById(loanId, disburseJson); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + + assertStatus(data, "loanStatusType.active"); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after disburse should include balance"); + assertEqualBigDecimal(transactionAmount, data.getAsJsonObject("balance").get("principalOutstanding")); + assertEqualBigDecimal(discountAmount, data.get("discount")); + assertTrue(data.has("id")); + assertEquals(loanId.longValue(), data.get("id").getAsLong()); + assertTrue(data.has("client") && !data.get("client").isJsonNull()); + assertTrue(data.has("product") && !data.get("product").isJsonNull()); + + if (data.has("timeline") && !data.get("timeline").isJsonNull()) { + final JsonObject timeline = data.getAsJsonObject("timeline"); + assertTrue(timeline.has("actualDisbursementDate")); + assertDateEquals(actualDisbursementDate, timeline.get("actualDisbursementDate")); + assertTrue(timeline.has("approvedOnDate")); + assertTrue(timeline.has("actualMaturityDate"), "timeline should include actualMaturityDate (null until fully paid)"); + assertTrue(timeline.get("actualMaturityDate").isJsonNull() || timeline.get("actualMaturityDate") == null, + "Expected actualMaturityDate to be null after disbursement"); + assertTrue(timeline.has("disbursementDetails") && timeline.get("disbursementDetails").isJsonArray(), + "timeline should include disbursementDetails list"); + assertFalse(timeline.getAsJsonArray("disbursementDetails").isEmpty(), "timeline disbursementDetails should not be empty"); + } + assertTrue(data.has("disbursementDetails") && data.get("disbursementDetails").isJsonArray(), + "GET loan after disburse should include disbursementDetails array"); + assertFalse(data.getAsJsonArray("disbursementDetails").isEmpty(), "disbursementDetails should not be empty"); + final JsonObject disbursement = data.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + assertTrue(disbursement.has("expectedDisbursementDate"), "disbursementDetails should include expectedDisbursementDate"); + assertTrue(disbursement.has("expectedAmount"), "disbursementDetails should include expectedAmount"); + assertTrue(disbursement.has("actualDisbursementDate")); + assertDateEquals(actualDisbursementDate, disbursement.get("actualDisbursementDate")); + assertTrue(disbursement.has("actualAmount")); + assertEqualBigDecimal(transactionAmount, disbursement.get("actualAmount")); + + assertTrue(data.has("transactions") && data.get("transactions").isJsonArray()); + assertEquals(1, data.getAsJsonArray("transactions").size()); + final JsonObject txn = data.getAsJsonArray("transactions").get(0).getAsJsonObject(); + assertEqualBigDecimal(transactionAmount, txn.get("transactionAmount")); + assertTrue(txn.has("principalPortion"), "Transaction should include allocation principalPortion"); + assertEqualBigDecimal(transactionAmount, txn.get("principalPortion")); + assertTrue(txn.has("feeChargesPortion"), "Transaction should include allocation feeChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion"), "Transaction should include allocation penaltyChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + assertTrue(txn.has("paymentDetailData") && !txn.get("paymentDetailData").isJsonNull(), + "Transaction should include paymentDetailData"); + final JsonObject paymentDetailData = txn.getAsJsonObject("paymentDetailData"); + assertEquals(accountNumber, paymentDetailData.get("accountNumber").getAsString()); + assertEquals(checkNumber, paymentDetailData.get("checkNumber").getAsString()); + assertEquals(routingCode, paymentDetailData.get("routingCode").getAsString()); + assertEquals(receiptNumber, paymentDetailData.get("receiptNumber").getAsString()); + assertEquals(bankNumber, paymentDetailData.get("bankNumber").getAsString()); + } + + @Test + public void testUndoDisburseWorkingCapitalLoan() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, BigDecimal.valueOf(5000))); + + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.approved"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), data.get("approvedPrincipal")); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after undo should include balance"); + assertEqualBigDecimal(BigDecimal.ZERO, data.getAsJsonObject("balance").get("principalOutstanding")); + + assertTrue(data.has("disbursementDetails") && data.get("disbursementDetails").isJsonArray(), + "GET loan after undo should include disbursementDetails array"); + assertFalse(data.getAsJsonArray("disbursementDetails").isEmpty(), "disbursementDetails should not be empty"); + final JsonObject disbursement = data.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + assertTrue(!disbursement.has("actualDisbursementDate") || disbursement.get("actualDisbursementDate").isJsonNull(), + "Expected actualDisbursementDate to be absent or null after undo"); + assertTrue(!disbursement.has("actualAmount") || disbursement.get("actualAmount").isJsonNull(), + "Expected actualAmount to be absent or null after undo"); + assertTrue(data.has("timeline") && !data.get("timeline").isJsonNull(), "GET loan after undo should include timeline"); + final JsonObject timeline = data.getAsJsonObject("timeline"); + assertTrue(timeline.has("actualMaturityDate"), "timeline should include actualMaturityDate (null until fully paid)"); + assertTrue(timeline.get("actualMaturityDate").isJsonNull() || timeline.get("actualMaturityDate") == null, + "Expected actualMaturityDate to be null after undo"); + + assertTrue(data.has("transactions") && data.get("transactions").isJsonArray(), "Expected transactions array in response"); + assertEquals(1, data.getAsJsonArray("transactions").size(), "Undo disburse should keep transaction history"); + final JsonObject txn = data.getAsJsonArray("transactions").get(0).getAsJsonObject(); + assertTrue(txn.has("reversed") && txn.get("reversed").getAsBoolean(), "Expected transaction to be reversed"); + } + + @Test + public void testUndoDisbursalWithNote() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson("Undo disbursal note")); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.approved"); + } + + @Test + public void testDisburseWithMissingActualDisbursementDate() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(null, BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("actualDisbursementDate") + && (ex.getDeveloperMessage().contains("mandatory") || ex.getDeveloperMessage().contains("null"))); + } + + @Test + public void testDisburseWithMissingTransactionAmount() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue( + ex.getDeveloperMessage().contains("transactionAmount") + && (ex.getDeveloperMessage().contains("mandatory") || ex.getDeveloperMessage().contains("null")), + "Expected message about mandatory transactionAmount: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithTransactionAmountExceedingApproved() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(6000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("amount.cannot.exceed.approved.principal")); + } + + @Test + public void testDisburseWithNegativeTransactionAmount() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(-100)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().toLowerCase().contains("transactionamount") || ex.getDeveloperMessage().contains("positive") + || ex.getDeveloperMessage().contains("greater")); + } + + @Test + public void testDisburseWithFutureDate() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final LocalDate futureDate = LocalDate.now(ZoneId.systemDefault()).plusDays(30); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(futureDate, BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("future.date") || ex.getDeveloperMessage().contains("actualDisbursementDate")); + } + + @Test + public void testDisburseWithDateBeforeApproval() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final LocalDate beforeApproval = approvedOnDate.minusDays(1); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(beforeApproval, BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("before.approval") || ex.getDeveloperMessage().contains("actualDisbursementDate")); + } + + @Test + public void testDisburseWithActualDateBeforeSubmittedDate() { + final Long productId = createProduct(); + + final LocalDate submittedOnDate = LocalDate.now(ZoneId.systemDefault()); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .withSubmittedOnDate(submittedOnDate) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(submittedOnDate)); + + final LocalDate actualDateBeforeSubmitted = submittedOnDate.minusDays(1); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDateBeforeSubmitted, + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("submitted") || ex.getDeveloperMessage().contains("actualDisbursementDate"), + "Expected message about actual date before submitted: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithNoteExceedingLength() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final String longNote = "a".repeat(1001); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, longNote, null, null, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("note") || ex.getDeveloperMessage().toLowerCase().contains("length"), + "Expected message about note length: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithDiscountExceedingCreated() { + final Long productId = createProductWithDiscountAllowed(); + + final BigDecimal approvedPrincipal = BigDecimal.valueOf(5000); + final BigDecimal approvedDiscount = BigDecimal.valueOf(20); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(approvedPrincipal) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), approvedPrincipal, approvedDiscount)); + + final BigDecimal discountAmountExceeding = BigDecimal.valueOf(25); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + approvedPrincipal, discountAmountExceeding, null, null, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("discount") && ex.getDeveloperMessage().contains("exceed")); + } + + @Test + public void testDisburseWithDuplicateTransactionExternalId() { + final Long productId = createProduct(); + + final String sharedExternalId = "wcl-txn-ext-" + UUID.randomUUID(); + + final Long loanId1 = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + applicationHelper.approveById(loanId1, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId1, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), + null, null, null, null, null, null, null, null, sharedExternalId)); + + final Long loanId2 = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(3000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(3300)) // + .buildSubmitJson()); + applicationHelper.approveById(loanId2, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final String disburseJson2 = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(3000), null, null, null, null, null, null, null, null, sharedExternalId); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId2, disburseJson2); + assertEquals(400, ex.getStatus()); + assertTrue(ex.getDeveloperMessage().contains("externalId") && ex.getDeveloperMessage().toLowerCase().contains("already"), + "Expected duplicate transaction externalId error: " + ex.getDeveloperMessage()); + + // cleanup: undo disbursal -> undo approval -> delete (like in @AfterEach), explicitly for this test too + applicationHelper.undoDisbursalById(loanId1, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + applicationHelper.undoApprovalById(loanId1, CLEANUP_EMPTY_COMMAND_JSON); + applicationHelper.deleteById(loanId1); + + applicationHelper.undoApprovalById(loanId2, CLEANUP_EMPTY_COMMAND_JSON); + applicationHelper.deleteById(loanId2); + + productHelper.deleteWorkingCapitalLoanProductById(productId); + } + + @Test + public void testDisburseWhenLoanNotApproved() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("Transition") || ex.getDeveloperMessage().contains("not allowed") + || ex.getDeveloperMessage().contains("status")); + } + + @Test + public void testDisburseNonExistentLoan() { + final long nonExistentLoanId = 999_999_999L; + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = assertThrows(CallFailedRuntimeException.class, + () -> applicationHelper.disburseById(nonExistentLoanId, disburseJson)); + assertEquals(404, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithUnsupportedParameterInPaymentDetails() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final String validDisburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, null, 1, "acc-123", null, null, null, null); + final JsonObject root = JsonParser.parseString(validDisburseJson).getAsJsonObject(); + root.getAsJsonObject("paymentDetails").addProperty("unsupportedField", "notAllowed"); + final String invalidJson = root.toString(); + + final String url = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "?command=disburse&" + Utils.TENANT_IDENTIFIER; + final ResponseSpecification badRequestSpec = new ResponseSpecBuilder().expectStatusCode(400).build(); + final String errorJson = Utils.performServerPost(requestSpec, badRequestSpec, url, invalidJson); + final JsonObject error = JsonParser.parseString(errorJson).getAsJsonObject(); + final String developerMessage = error.has("errors") && error.get("errors").isJsonArray() + && !error.getAsJsonArray("errors").isEmpty() + ? error.getAsJsonArray("errors").get(0).getAsJsonObject().get("developerMessage").getAsString() + : errorJson; + assertNotNull(developerMessage); + assertTrue(developerMessage.contains("unsupportedField") || developerMessage.toLowerCase().contains("unsupported"), + "Expected message about unsupported parameter in paymentDetails: " + developerMessage); + } + + @Test + public void testDisburseWithUnsupportedTopLevelParameter() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final String validDisburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + final JsonObject root = JsonParser.parseString(validDisburseJson).getAsJsonObject(); + root.addProperty("accountNumber", "flatNotAllowed"); + final String invalidJson = root.toString(); + + final String url = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "?command=disburse&" + Utils.TENANT_IDENTIFIER; + final ResponseSpecification badRequestSpec = new ResponseSpecBuilder().expectStatusCode(400).build(); + final String errorJson = Utils.performServerPost(requestSpec, badRequestSpec, url, invalidJson); + final JsonObject error = JsonParser.parseString(errorJson).getAsJsonObject(); + final String developerMessage = error.has("errors") && error.get("errors").isJsonArray() + && !error.getAsJsonArray("errors").isEmpty() + ? error.getAsJsonArray("errors").get(0).getAsJsonObject().get("developerMessage").getAsString() + : errorJson; + assertNotNull(developerMessage); + assertTrue(developerMessage.contains("accountNumber") || developerMessage.toLowerCase().contains("unsupported"), + "Expected message about unsupported top-level parameter: " + developerMessage); + } + + @Test + public void testUndoDisbursalWhenLoanNotDisbursed() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate)); + + final CallFailedRuntimeException ex = applicationHelper.runUndoDisbursalExpectingFailure(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("Transition") || ex.getDeveloperMessage().contains("not allowed") + || ex.getDeveloperMessage().contains("status")); + } + + @Test + public void testUndoDisbursalNonExistentLoan() { + final long nonExistentLoanId = 999_999_999L; + final CallFailedRuntimeException ex = assertThrows(CallFailedRuntimeException.class, () -> applicationHelper + .undoDisbursalById(nonExistentLoanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson())); + assertEquals(404, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + } + + @Test + public void testUndoDisbursalWithNoteExceedingLength() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final String longNote = "a".repeat(1001); + final CallFailedRuntimeException ex = applicationHelper.runUndoDisbursalExpectingFailure(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson(longNote)); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("note") || ex.getDeveloperMessage().toLowerCase().contains("length"), + "Expected message about note length: " + ex.getDeveloperMessage()); + } + + @Test + public void testGetTransactionsListAfterDisburse() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final String transactionsUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions?" + + Utils.TENANT_IDENTIFIER; + final String json = Utils.performServerGet(requestSpec, responseSpec, transactionsUrl); + assertNotNull(json); + final JsonObject page = JsonParser.parseString(json).getAsJsonObject(); + assertTrue(page.has("content"), "Response should have content array"); + assertTrue(page.has("totalElements")); + final JsonArray content = page.getAsJsonArray("content"); + assertEquals(1, content.size(), "After one disburse there should be one transaction"); + assertEquals(1L, page.get("totalElements").getAsLong()); + final JsonObject txn = content.get(0).getAsJsonObject(); + assertTrue(txn.has("id") && txn.has("type") && txn.has("transactionAmount")); + assertEquals("loanTransactionType.disbursement", txn.getAsJsonObject("type").get("code").getAsString()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("transactionAmount")); + assertTrue(txn.has("principalPortion")); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("principalPortion")); + assertTrue(txn.has("feeChargesPortion")); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion")); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + assertFalse(txn.get("reversed").getAsBoolean()); + } + + @Test + public void testGetTransactionsListPagination() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final LocalDate today = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(today, BigDecimal.valueOf(5000))); + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(today, BigDecimal.valueOf(5000))); + + final String page0Url = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions?page=0&size=1&" + + Utils.TENANT_IDENTIFIER; + final String page0Json = Utils.performServerGet(requestSpec, responseSpec, page0Url); + final JsonObject page0 = JsonParser.parseString(page0Json).getAsJsonObject(); + assertEquals(2L, page0.get("totalElements").getAsLong(), "totalElements should reflect two transactions"); + assertEquals(1, page0.getAsJsonArray("content").size(), "page size=1 should return one transaction"); + final long firstTxnId = page0.getAsJsonArray("content").get(0).getAsJsonObject().get("id").getAsLong(); + + final String page1Url = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions?page=1&size=1&" + + Utils.TENANT_IDENTIFIER; + final String page1Json = Utils.performServerGet(requestSpec, responseSpec, page1Url); + final JsonObject page1 = JsonParser.parseString(page1Json).getAsJsonObject(); + assertEquals(2L, page1.get("totalElements").getAsLong()); + assertEquals(1, page1.getAsJsonArray("content").size()); + final long secondTxnId = page1.getAsJsonArray("content").get(0).getAsJsonObject().get("id").getAsLong(); + + assertTrue(firstTxnId != secondTxnId, "page 0 and page 1 should return different transactions"); + } + + @Test + public void testGetTransactionByIdAfterDisburse() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(6000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(6600)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(6000))); + + final String listUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions?" + Utils.TENANT_IDENTIFIER; + final String listJson = Utils.performServerGet(requestSpec, responseSpec, listUrl); + final JsonArray content = JsonParser.parseString(listJson).getAsJsonObject().getAsJsonArray("content"); + assertEquals(1, content.size()); + final long transactionId = content.get(0).getAsJsonObject().get("id").getAsLong(); + + final String txnUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions/" + transactionId + "?" + + Utils.TENANT_IDENTIFIER; + final String txnJson = Utils.performServerGet(requestSpec, responseSpec, txnUrl); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEquals(transactionId, txn.get("id").getAsLong()); + assertEqualBigDecimal(BigDecimal.valueOf(6000), txn.get("transactionAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(6000), txn.get("principalPortion")); + assertTrue(txn.has("transactionDate") && txn.has("reversed")); + assertTrue(txn.has("type"), "GET transaction should include type"); + assertEquals("loanTransactionType.disbursement", txn.getAsJsonObject("type").get("code").getAsString()); + assertTrue(txn.has("submittedOnDate"), "GET transaction should include submittedOnDate"); + assertFalse(txn.has("interestPortion"), "WCL has no interest"); + assertTrue(txn.has("feeChargesPortion"), "GET transaction should include allocation feeChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion"), "GET transaction should include allocation penaltyChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + + } + + @Test + public void testGetTransactionsListEmptyWhenNotDisbursed() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final String transactionsUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions?" + + Utils.TENANT_IDENTIFIER; + final String json = Utils.performServerGet(requestSpec, responseSpec, transactionsUrl); + final JsonObject page = JsonParser.parseString(json).getAsJsonObject(); + assertTrue(page.has("content")); + assertTrue(page.getAsJsonArray("content").isEmpty(), "Before disburse transactions list should be empty"); + + } + + @Test + public void testGetTransactionByNonExistentIdReturns404() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final long nonExistentTransactionId = 999_999L; + final String txnUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions/" + nonExistentTransactionId + + "?" + Utils.TENANT_IDENTIFIER; + final ResponseSpecification notFoundSpec = new ResponseSpecBuilder().expectStatusCode(404).build(); + RestAssured.given().spec(requestSpec).expect().spec(notFoundSpec).when().get(txnUrl); + } + + @Test + public void testGetTransactionsByLoanExternalId() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-loan-ext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final String transactionsUrl = "/fineract-provider/api/v1/working-capital-loans/external-id/" + loanExternalId + "/transactions?" + + Utils.TENANT_IDENTIFIER; + final String json = Utils.performServerGet(requestSpec, responseSpec, transactionsUrl); + assertNotNull(json); + final JsonObject page = JsonParser.parseString(json).getAsJsonObject(); + assertTrue(page.has("content") && page.has("totalElements")); + final JsonArray content = page.getAsJsonArray("content"); + assertEquals(1, content.size()); + final JsonObject txn = content.get(0).getAsJsonObject(); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("transactionAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("principalPortion")); + } + + @Test + public void testGetTransactionByLoanIdAndTransactionExternalId() { + final Long productId = createProduct(); + + final String txnExternalId = "wcl-txn-ext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(7000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(7700)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(7000), + null, null, null, null, null, null, null, null, txnExternalId)); + + final String txnUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions/external-id/" + txnExternalId + + "?" + Utils.TENANT_IDENTIFIER; + final String txnJson = Utils.performServerGet(requestSpec, responseSpec, txnUrl); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEqualBigDecimal(BigDecimal.valueOf(7000), txn.get("transactionAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(7000), txn.get("principalPortion")); + assertTrue(txn.has("externalId") && txnExternalId.equals(txn.get("externalId").getAsString())); + } + + @Test + public void testStateTransitionByLoanExternalId_ApproveAndDisburse() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-ext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + final String approveUrl = "/fineract-provider/api/v1/working-capital-loans/external-id/" + loanExternalId + "?command=approve&" + + Utils.TENANT_IDENTIFIER; + final String approveJson = WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault())); + Utils.performServerPost(requestSpec, responseSpec, approveUrl, approveJson); + + final String disburseUrl = "/fineract-provider/api/v1/working-capital-loans/external-id/" + loanExternalId + "?command=disburse&" + + Utils.TENANT_IDENTIFIER; + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + Utils.performServerPost(requestSpec, responseSpec, disburseUrl, disburseJson); + + final String response = applicationHelper.retrieveById(loanId); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.active"); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after disburse should include balance"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), data.getAsJsonObject("balance").get("principalOutstanding")); + } + + @Test + public void testGetTransactionByExternalLoanIdAndTransactionId() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-lext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(8000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(8800)) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(8000))); + + final String listUrl = "/fineract-provider/api/v1/working-capital-loans/" + loanId + "/transactions?" + Utils.TENANT_IDENTIFIER; + final String listJson = Utils.performServerGet(requestSpec, responseSpec, listUrl); + final long transactionId = JsonParser.parseString(listJson).getAsJsonObject().getAsJsonArray("content").get(0).getAsJsonObject() + .get("id").getAsLong(); + + final String txnUrl = "/fineract-provider/api/v1/working-capital-loans/external-id/" + loanExternalId + "/transactions/" + + transactionId + "?" + Utils.TENANT_IDENTIFIER; + final String txnJson = Utils.performServerGet(requestSpec, responseSpec, txnUrl); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEquals(transactionId, txn.get("id").getAsLong()); + assertEqualBigDecimal(BigDecimal.valueOf(8000), txn.get("transactionAmount")); + } + + @Test + public void testGetTransactionByExternalLoanIdAndTransactionExternalId() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-lext2-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final String txnExternalId = "wcl-text-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(9000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(9900)) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(9000), + null, null, null, null, null, null, null, null, txnExternalId)); + + final String txnUrl = "/fineract-provider/api/v1/working-capital-loans/external-id/" + loanExternalId + "/transactions/external-id/" + + txnExternalId + "?" + Utils.TENANT_IDENTIFIER; + final String txnJson = Utils.performServerGet(requestSpec, responseSpec, txnUrl); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEqualBigDecimal(BigDecimal.valueOf(9000), txn.get("transactionAmount")); + assertEquals(txnExternalId, txn.get("externalId").getAsString()); + } + + @Test + public void testDisburseWhenClientNotActive() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec); + clientHelper.closeClient(createdClientId.intValue()); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(403, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().toLowerCase().contains("client") && ex.getDeveloperMessage().toLowerCase().contains("active"), + "Expected message about client not active: " + ex.getDeveloperMessage()); + + clientHelper.reactivateClient(createdClientId.intValue()); + } + + @Test + public void testDisburseWithInvalidPaymentTypeId() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, null, 0, null, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("paymentTypeId") || ex.getDeveloperMessage().toLowerCase().contains("payment"), + "Expected message about invalid paymentTypeId: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithPaymentDetailsStringExceedingLength() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withTotalPayment(BigDecimal.valueOf(5500)) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()))); + + final String longAccountNumber = "a".repeat(51); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, null, null, longAccountNumber, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("accountNumber") || ex.getDeveloperMessage().toLowerCase().contains("length"), + "Expected message about accountNumber length: " + ex.getDeveloperMessage()); + } + + private static void assertStatus(final JsonObject data, final String expectedStatusCode) { + assertTrue(data.has("status") && !data.get("status").isJsonNull()); + assertEquals(expectedStatusCode, data.getAsJsonObject("status").get("code").getAsString()); + } + + private static void assertEqualBigDecimal(final BigDecimal expected, final JsonElement actual) { + assertNotNull(actual, "Expected value for field"); + assertFalse(actual.isJsonNull(), "Expected non-null value"); + assertEquals(0, expected.compareTo(actual.getAsJsonPrimitive().getAsBigDecimal()), + "Expected " + expected + " but got " + actual.getAsString()); + } + + private static void assertDateEquals(final LocalDate expected, final JsonElement actual) { + assertNotNull(actual, "Expected date value"); + assertFalse(actual.isJsonNull(), "Expected non-null date"); + if (actual.isJsonArray()) { + final 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.format(DateTimeFormatter.ISO_LOCAL_DATE), actual.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); + final Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createProductWithDiscountAllowed() { + final String uniqueName = "WCL Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder() // + .withName(uniqueName) // + .withShortName(uniqueShortName) // + .withAllowAttributeOverrides(java.util.Map.of("discountDefault", Boolean.TRUE)) // + .build()) // + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createClient() { + return ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + } + + private Long submitAndTrack(final String submitJson) { + final Long loanId = applicationHelper.submit(submitJson); + createdLoanIds.add(loanId); + return loanId; + } +} 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 de4853ce7de..7ac84cc1f9f 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 @@ -18,6 +18,7 @@ */ package org.apache.fineract.integrationtests.common.workingcapitalloan; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; @@ -38,6 +39,8 @@ public class WorkingCapitalLoanApplicationHelper { private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getShared(); + private static final ObjectMapper RESPONSE_OBJECT_MAPPER = ObjectMapperFactory.getShared().copy() + .setSerializationInclusion(JsonInclude.Include.ALWAYS); public WorkingCapitalLoanApplicationHelper() {} @@ -122,6 +125,16 @@ public Long undoApprovalByExternalId(final String externalId, final String jsonB .getResourceId(); } + public Long disburseById(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.ok(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "disburse", request)).getResourceId(); + } + + public Long undoDisbursalById(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.ok(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "undodisbursal", 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)); @@ -137,6 +150,16 @@ public CallFailedRuntimeException runUndoApprovalExpectingFailure(final Long loa return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "undoapproval", request)); } + public CallFailedRuntimeException runDisburseExpectingFailure(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "disburse", request)); + } + + public CallFailedRuntimeException runUndoDisbursalExpectingFailure(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "undodisbursal", request)); + } + /** * For validation tests: run submit expecting failure. */ @@ -163,7 +186,7 @@ private static T fromJson(String json, Class type) { private static String toJson(Object value) { try { - return OBJECT_MAPPER.writeValueAsString(value); + return RESPONSE_OBJECT_MAPPER.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalArgumentException("Failed to serialize response", e); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java new file mode 100644 index 00000000000..1aaaf30da85 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java @@ -0,0 +1,108 @@ +/** + * 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.common.workingcapitalloan; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * Builds JSON request bodies for Working Capital Loan Disbursement API. + */ +public final class WorkingCapitalLoanDisbursementTestBuilder { + + private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; + private static final String DEFAULT_LOCALE = "en"; + + private WorkingCapitalLoanDisbursementTestBuilder() {} + + public static String buildDisburseJson(final LocalDate actualDisbursementDate, final BigDecimal transactionAmount, + final BigDecimal discountAmount, final String note, final Integer paymentTypeId, final String accountNumber, + final String checkNumber, final String routingCode, final String receiptNumber, final String bankNumber, + final String externalId) { + final JsonObject json = new JsonObject(); + json.addProperty("locale", DEFAULT_LOCALE); + json.addProperty("dateFormat", DEFAULT_DATE_FORMAT); + if (actualDisbursementDate != null) { + json.addProperty("actualDisbursementDate", actualDisbursementDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + if (transactionAmount != null) { + json.addProperty("transactionAmount", transactionAmount); + } + if (discountAmount != null) { + json.addProperty("discountAmount", discountAmount); + } + if (note != null) { + json.addProperty("note", note); + } + if (paymentTypeId != null || accountNumber != null || checkNumber != null || routingCode != null || receiptNumber != null + || bankNumber != null) { + final JsonObject paymentDetails = new JsonObject(); + if (paymentTypeId != null) { + paymentDetails.addProperty("paymentTypeId", paymentTypeId); + } + if (accountNumber != null) { + paymentDetails.addProperty("accountNumber", accountNumber); + } + if (checkNumber != null) { + paymentDetails.addProperty("checkNumber", checkNumber); + } + if (routingCode != null) { + paymentDetails.addProperty("routingCode", routingCode); + } + if (receiptNumber != null) { + paymentDetails.addProperty("receiptNumber", receiptNumber); + } + if (bankNumber != null) { + paymentDetails.addProperty("bankNumber", bankNumber); + } + json.add("paymentDetails", paymentDetails); + } + if (externalId != null) { + json.addProperty("externalId", externalId); + } + return json.toString(); + } + + public static String buildDisburseJson(final LocalDate actualDisbursementDate, final BigDecimal transactionAmount, + final BigDecimal discountAmount, final String note, final Integer paymentTypeId, final String accountNumber, + final String checkNumber, final String routingCode, final String receiptNumber, final String bankNumber) { + return buildDisburseJson(actualDisbursementDate, transactionAmount, discountAmount, note, paymentTypeId, accountNumber, checkNumber, + routingCode, receiptNumber, bankNumber, null); + } + + public static String buildDisburseJson(final LocalDate actualDisbursementDate, final BigDecimal transactionAmount) { + return buildDisburseJson(actualDisbursementDate, transactionAmount, null, null, null, null, null, null, null, null); + } + + public static String buildUndoDisburseJson() { + return buildUndoDisburseJson(null); + } + + public static String buildUndoDisburseJson(final String note) { + final JsonObject json = new JsonObject(); + json.addProperty("locale", DEFAULT_LOCALE); + json.addProperty("dateFormat", DEFAULT_DATE_FORMAT); + if (note != null) { + json.addProperty("note", note); + } + return json.toString(); + } +}