diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java index e2851b5db61..1fa7ab3a5ca 100644 --- a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java +++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java @@ -162,6 +162,7 @@ import org.apache.fineract.client.feign.services.WorkingCapitalLoanDelinquencyActionsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanDelinquencyRangeScheduleApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanInternalCobApiApi; +import org.apache.fineract.client.feign.services.WorkingCapitalLoanOriginatorsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanProductsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoanTransactionsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoansApi; @@ -807,6 +808,10 @@ public WorkingCapitalNearBreachApi workingCapitalNearBreaches() { return create(WorkingCapitalNearBreachApi.class); } + public WorkingCapitalLoanOriginatorsApi workingCapitalLoanOriginators() { + return create(WorkingCapitalLoanOriginatorsApi.class); + } + public WorkingDaysApi workingDays() { return create(WorkingDaysApi.class); } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapperConstants.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapperConstants.java index be71670437b..7113f1d163b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapperConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapperConstants.java @@ -251,6 +251,7 @@ private CommandWrapperConstants() {} public static final String ENTITY_DELINQUENCY_ACTION = "DELINQUENCY_ACTION"; public static final String ENTITY_LOAN_AVAILABLE_DISBURSEMENT_AMOUNT = "LOAN_AVAILABLE_DISBURSEMENT_AMOUNT"; public static final String ENTITY_LOAN_ORIGINATOR = "LOAN_ORIGINATOR"; + public static final String ENTITY_WORKING_CAPITAL_LOAN_ORIGINATOR = "WORKING_CAPITAL_LOAN_ORIGINATOR"; public static final String ENTITY_WORKINGDAYS = "WORKINGDAYS"; public static final String ENTITY_SHAREPRODUCT = "SHAREPRODUCT"; public static final String ENTITY_INTEREST_PAUSE = "INTEREST_PAUSE"; 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 f4a0dc6e19d..ac212e6bac3 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 @@ -243,6 +243,7 @@ import static org.apache.fineract.commands.domain.CommandWrapperConstants.ENTITY_WORKINGCAPITALLOAN; import static org.apache.fineract.commands.domain.CommandWrapperConstants.ENTITY_WORKINGCAPITALLOANCHARGE; import static org.apache.fineract.commands.domain.CommandWrapperConstants.ENTITY_WORKINGCAPITALLOANPRODUCT; +import static org.apache.fineract.commands.domain.CommandWrapperConstants.ENTITY_WORKING_CAPITAL_LOAN_ORIGINATOR; import static org.apache.fineract.useradministration.service.AppUserConstants.PASSWORD; import static org.apache.fineract.useradministration.service.AppUserConstants.REPEAT_PASSWORD; @@ -4077,4 +4078,24 @@ public CommandWrapperBuilder undoAccountTransfer(final Long transferId) { this.href = "/accounttransfers"; return this; } + + public CommandWrapperBuilder attachWorkingCapitalLoanOriginator(final Long loanId, final Long originatorId) { + this.actionName = ACTION_ATTACH; + this.entityName = ENTITY_WORKING_CAPITAL_LOAN_ORIGINATOR; + this.entityId = loanId; + this.loanId = loanId; + this.subentityId = originatorId; + this.href = "/working-capital-loans/" + loanId + "/originators/" + originatorId; + return this; + } + + public CommandWrapperBuilder detachWorkingCapitalLoanOriginator(final Long loanId, final Long originatorId) { + this.actionName = ACTION_DETACH; + this.entityName = ENTITY_WORKING_CAPITAL_LOAN_ORIGINATOR; + this.entityId = loanId; + this.loanId = loanId; + this.subentityId = originatorId; + this.href = "/working-capital-loans/" + loanId + "/originators/" + originatorId; + return this; + } } diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/api/LoanOriginatorsApiResource.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/api/LoanOriginatorsApiResource.java index f456c5a5a96..08fdb9e3d5c 100644 --- a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/api/LoanOriginatorsApiResource.java +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/api/LoanOriginatorsApiResource.java @@ -20,6 +20,8 @@ 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.tags.Tag; import jakarta.ws.rs.Consumes; @@ -64,7 +66,7 @@ public class LoanOriginatorsApiResource { @Path("{loanId}/originators") @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Retrieve originators for a loan by loan ID", description = "Retrieves all originators attached to a specific loan. Requires READ_LOAN permission.") - @ApiResponse(responseCode = "200", description = "OK - Returns wrapped list of originators (may be empty)") + @ApiResponse(responseCode = "200", description = "OK - Returns wrapped list of originators (may be empty)", content = @Content(schema = @Schema(implementation = LoanOriginatorsResponse.class))) @ApiResponse(responseCode = "403", description = "Insufficient permissions") @ApiResponse(responseCode = "404", description = "Loan not found") public LoanOriginatorsResponse retrieveOriginatorsByLoanId(@PathParam("loanId") @Parameter(description = "loanId") final Long loanId) { @@ -81,7 +83,7 @@ public LoanOriginatorsResponse retrieveOriginatorsByLoanId(@PathParam("loanId") @Path("external-id/{loanExternalId}/originators") @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Retrieve originators for a loan by loan external ID", description = "Retrieves all originators attached to a specific loan using loan external ID. Requires READ_LOAN permission.") - @ApiResponse(responseCode = "200", description = "OK - Returns wrapped list of originators (may be empty)") + @ApiResponse(responseCode = "200", description = "OK - Returns wrapped list of originators (may be empty)", content = @Content(schema = @Schema(implementation = LoanOriginatorsResponse.class))) @ApiResponse(responseCode = "403", description = "Insufficient permissions") @ApiResponse(responseCode = "404", description = "Loan not found") public LoanOriginatorsResponse retrieveOriginatorsByLoanExternalId( @@ -102,7 +104,7 @@ public LoanOriginatorsResponse retrieveOriginatorsByLoanExternalId( @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Attach originator to loan by IDs", description = "Attaches an originator to a loan. Loan must be in 'Submitted and Pending Approval' status. Requires ATTACH_LOAN_ORIGINATOR permission.") - @ApiResponse(responseCode = "200", description = "OK - Originator attached") + @ApiResponse(responseCode = "200", description = "OK - Originator attached", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping, or insufficient permissions") @ApiResponse(responseCode = "404", description = "Loan or originator not found") public LoanOriginatorMappingResponse attachOriginatorToLoan(@PathParam("loanId") @Parameter(description = "loanId") final Long loanId, @@ -119,7 +121,7 @@ public LoanOriginatorMappingResponse attachOriginatorToLoan(@PathParam("loanId") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Attach originator to loan by loan ID and originator external ID") - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping") @ApiResponse(responseCode = "404", description = "Loan or originator not found") public LoanOriginatorMappingResponse attachOriginatorToLoanByOriginatorExternalId( @@ -139,7 +141,7 @@ public LoanOriginatorMappingResponse attachOriginatorToLoanByOriginatorExternalI @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Attach originator to loan by loan external ID and originator ID") - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping") @ApiResponse(responseCode = "404", description = "Loan or originator not found") public LoanOriginatorMappingResponse attachOriginatorToLoanByLoanExternalId( @@ -163,7 +165,7 @@ public LoanOriginatorMappingResponse attachOriginatorToLoanByLoanExternalId( @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Attach originator to loan by external IDs") - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping") @ApiResponse(responseCode = "404", description = "Loan or originator not found") public LoanOriginatorMappingResponse attachOriginatorToLoanByExternalIds( @@ -188,7 +190,7 @@ public LoanOriginatorMappingResponse attachOriginatorToLoanByExternalIds( @Path("{loanId}/originators/{originatorId}") @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Detach originator from loan by IDs", description = "Detaches an originator from a loan. Loan must be in 'Submitted and Pending Approval' status. Requires DETACH_LOAN_ORIGINATOR permission.") - @ApiResponse(responseCode = "200", description = "OK - Originator detached") + @ApiResponse(responseCode = "200", description = "OK - Originator detached", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status or insufficient permissions") @ApiResponse(responseCode = "404", description = "Loan, originator, or mapping not found") public LoanOriginatorMappingResponse detachOriginatorFromLoan(@PathParam("loanId") @Parameter(description = "loanId") final Long loanId, @@ -204,7 +206,7 @@ public LoanOriginatorMappingResponse detachOriginatorFromLoan(@PathParam("loanId @Path("{loanId}/originators/external-id/{originatorExternalId}") @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Detach originator from loan by loan ID and originator external ID") - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status") @ApiResponse(responseCode = "404", description = "Loan, originator, or mapping not found") public LoanOriginatorMappingResponse detachOriginatorFromLoanByOriginatorExternalId( @@ -223,7 +225,7 @@ public LoanOriginatorMappingResponse detachOriginatorFromLoanByOriginatorExterna @Path("external-id/{loanExternalId}/originators/{originatorId}") @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Detach originator from loan by loan external ID and originator ID") - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status") @ApiResponse(responseCode = "404", description = "Loan, originator, or mapping not found") public LoanOriginatorMappingResponse detachOriginatorFromLoanByLoanExternalId( @@ -246,7 +248,7 @@ public LoanOriginatorMappingResponse detachOriginatorFromLoanByLoanExternalId( @Path("external-id/{loanExternalId}/originators/external-id/{originatorExternalId}") @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Detach originator from loan by external IDs") - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) @ApiResponse(responseCode = "403", description = "Loan not in correct status") @ApiResponse(responseCode = "404", description = "Loan, originator, or mapping not found") public LoanOriginatorMappingResponse detachOriginatorFromLoanByExternalIds( diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/domain/WorkingCapitalLoanOriginatorMapping.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/domain/WorkingCapitalLoanOriginatorMapping.java new file mode 100644 index 00000000000..56ead562e58 --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/domain/WorkingCapitalLoanOriginatorMapping.java @@ -0,0 +1,52 @@ +/** + * 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.loanorigination.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +@Getter +@Setter +@Entity +@NoArgsConstructor +@Table(name = "m_wc_loan_originator_mapping") +public class WorkingCapitalLoanOriginatorMapping extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "loan_id", nullable = false) + private Long loanId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "originator_id", nullable = false) + private LoanOriginator originator; + + public static WorkingCapitalLoanOriginatorMapping create(Long loanId, LoanOriginator originator) { + WorkingCapitalLoanOriginatorMapping mapping = new WorkingCapitalLoanOriginatorMapping(); + mapping.setLoanId(loanId); + mapping.setOriginator(originator); + return mapping; + } +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/domain/WorkingCapitalLoanOriginatorMappingRepository.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/domain/WorkingCapitalLoanOriginatorMappingRepository.java new file mode 100644 index 00000000000..b9bb198b74e --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/domain/WorkingCapitalLoanOriginatorMappingRepository.java @@ -0,0 +1,44 @@ +/** + * 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.loanorigination.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface WorkingCapitalLoanOriginatorMappingRepository + extends JpaRepository, JpaSpecificationExecutor { + + Optional findByLoanIdAndOriginatorId(Long loanId, Long originatorId); + + boolean existsByLoanIdAndOriginatorId(Long loanId, Long originatorId); + + @org.springframework.data.jpa.repository.Query(""" + SELECT m FROM WorkingCapitalLoanOriginatorMapping m + JOIN FETCH m.originator o + LEFT JOIN FETCH o.originatorType + LEFT JOIN FETCH o.channelType + WHERE m.loanId = :loanId + """) + List findByLoanIdWithOriginator( + @org.springframework.data.repository.query.Param("loanId") Long loanId); +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/WorkingCapitalLoanOriginatorMappingNotFoundException.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/WorkingCapitalLoanOriginatorMappingNotFoundException.java new file mode 100644 index 00000000000..b53041d1480 --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/WorkingCapitalLoanOriginatorMappingNotFoundException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanorigination.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class WorkingCapitalLoanOriginatorMappingNotFoundException extends AbstractPlatformResourceNotFoundException { + + public WorkingCapitalLoanOriginatorMappingNotFoundException(Long loanId, Long originatorId) { + super("error.msg.wc.loan.originator.mapping.not.found", + "Originator with id " + originatorId + " is not attached to working capital loan with id " + loanId, loanId, originatorId); + } +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/AbstractLoanOriginatorLinkingServiceImpl.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/AbstractLoanOriginatorLinkingServiceImpl.java new file mode 100644 index 00000000000..518c72ad963 --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/AbstractLoanOriginatorLinkingServiceImpl.java @@ -0,0 +1,118 @@ +/** + * 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.loanorigination.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.sql.SQLException; +import java.util.HashSet; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService; +import org.apache.fineract.portfolio.loanorigination.data.LoanApplicationOriginatorData; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus; +import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotActiveException; +import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotFoundException; +import org.apache.fineract.portfolio.loanorigination.serialization.LoanApplicationOriginatorDataValidator; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +public abstract class AbstractLoanOriginatorLinkingServiceImpl implements LoanOriginatorLinkingService { + + private static final String SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION = "23"; + + protected final LoanOriginatorRepository loanOriginatorRepository; + protected final LoanApplicationOriginatorDataValidator validator; + protected final LoanOriginatorHelper loanOriginatorHelper; + + public AbstractLoanOriginatorLinkingServiceImpl(LoanOriginatorRepository loanOriginatorRepository, + LoanApplicationOriginatorDataValidator validator, LoanOriginatorHelper loanOriginatorHelper) { + this.loanOriginatorRepository = loanOriginatorRepository; + this.validator = validator; + this.loanOriginatorHelper = loanOriginatorHelper; + } + + @Transactional + @Override + public void processOriginatorsForLoanApplication(final Long loanId, final JsonArray originatorsArray) { + if (originatorsArray == null || originatorsArray.isEmpty()) { + return; + } + + log.debug("Processing {} originators for loan application {}", originatorsArray.size(), loanId); + + final Set attachedOriginatorIds = new HashSet<>(); + + for (final JsonElement element : originatorsArray) { + if (!element.isJsonObject()) { + continue; + } + + final JsonObject jsonObject = element.getAsJsonObject(); + final LoanApplicationOriginatorData originatorData = validator.validateAndExtract(jsonObject); + final Long originatorId = resolveOrCreateOriginatorId(originatorData); + + if (attachedOriginatorIds.contains(originatorId)) { + log.debug("Originator {} already attached to loan {}, skipping duplicate", originatorId, loanId); + continue; + } + + createAndSaveOriginatorMapping(loanId, originatorId); + + attachedOriginatorIds.add(originatorId); + } + } + + protected abstract void createAndSaveOriginatorMapping(Long loanId, Long originatorId); + + private Long resolveOrCreateOriginatorId(final LoanApplicationOriginatorData originatorData) { + if (originatorData.getId() != null) { + final LoanOriginator originator = loanOriginatorRepository.findById(originatorData.getId()) + .orElseThrow(() -> new LoanOriginatorNotFoundException(originatorData.getId())); + if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) { + throw new LoanOriginatorNotActiveException(originator.getId(), originator.getStatus().getValue()); + } + return originator.getId(); + } + return findOrCreateOriginatorIdByExternalId(originatorData); + } + + private Long findOrCreateOriginatorIdByExternalId(final LoanApplicationOriginatorData originatorData) { + try { + return loanOriginatorHelper.findOrCreateOriginatorId(originatorData); + } catch (final JpaSystemException | DataIntegrityViolationException e) { + if (!isConstraintViolation(e)) { + throw e; + } + // Another thread created the originator concurrently - retry + return loanOriginatorHelper.findOrCreateOriginatorId(originatorData); + } + } + + private boolean isConstraintViolation(final DataAccessException e) { + return e.getMostSpecificCause() instanceof SQLException sqlEx && sqlEx.getSQLState() != null + && sqlEx.getSQLState().startsWith(SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION); + } +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java index edfc273f9c0..60a26042420 100644 --- a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java @@ -18,110 +18,43 @@ */ package org.apache.fineract.portfolio.loanorigination.service; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import java.sql.SQLException; -import java.util.HashSet; -import java.util.Set; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService; -import org.apache.fineract.portfolio.loanorigination.data.LoanApplicationOriginatorData; import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMapping; import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorMappingRepository; import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; -import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus; -import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotActiveException; -import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotFoundException; import org.apache.fineract.portfolio.loanorigination.serialization.LoanApplicationOriginatorDataValidator; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.dao.DataAccessException; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * Implementation of {@link LoanOriginatorLinkingService} that handles processing of originators during loan * application. This service is active only when the loan-origination module is enabled. */ @Slf4j +@Primary @Service("loanOriginatorLinkingServiceImpl") -@RequiredArgsConstructor @ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") -public class LoanOriginatorLinkingServiceImpl implements LoanOriginatorLinkingService { +public class LoanOriginatorLinkingServiceImpl extends AbstractLoanOriginatorLinkingServiceImpl { - private static final String SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION = "23"; - - private final LoanOriginatorRepository loanOriginatorRepository; private final LoanOriginatorMappingRepository loanOriginatorMappingRepository; - private final LoanApplicationOriginatorDataValidator validator; - private final LoanOriginatorHelper loanOriginatorHelper; - - @Transactional - @Override - public void processOriginatorsForLoanApplication(final Long loanId, final JsonArray originatorsArray) { - if (originatorsArray == null || originatorsArray.isEmpty()) { - return; - } - - log.debug("Processing {} originators for loan application {}", originatorsArray.size(), loanId); - - final Set attachedOriginatorIds = new HashSet<>(); - - for (final JsonElement element : originatorsArray) { - if (!element.isJsonObject()) { - continue; - } - - final JsonObject jsonObject = element.getAsJsonObject(); - final LoanApplicationOriginatorData originatorData = validator.validateAndExtract(jsonObject); - final Long originatorId = resolveOrCreateOriginatorId(originatorData); - - if (attachedOriginatorIds.contains(originatorId)) { - log.debug("Originator {} already attached to loan {}, skipping duplicate", originatorId, loanId); - continue; - } - if (!loanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId, originatorId)) { - final LoanOriginator originatorRef = loanOriginatorRepository.getReferenceById(originatorId); - final LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originatorRef); - loanOriginatorMappingRepository.save(mapping); - log.debug("Attached originator {} to loan {}", originatorId, loanId); - } - - attachedOriginatorIds.add(originatorId); - } - } - - private Long resolveOrCreateOriginatorId(final LoanApplicationOriginatorData originatorData) { - if (originatorData.getId() != null) { - final LoanOriginator originator = loanOriginatorRepository.findById(originatorData.getId()) - .orElseThrow(() -> new LoanOriginatorNotFoundException(originatorData.getId())); - if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) { - throw new LoanOriginatorNotActiveException(originator.getId(), originator.getStatus().getValue()); - } - return originator.getId(); - } - return findOrCreateOriginatorIdByExternalId(originatorData); + public LoanOriginatorLinkingServiceImpl(LoanOriginatorRepository loanOriginatorRepository, + LoanApplicationOriginatorDataValidator validator, LoanOriginatorHelper loanOriginatorHelper, + LoanOriginatorMappingRepository loanOriginatorMappingRepository) { + super(loanOriginatorRepository, validator, loanOriginatorHelper); + this.loanOriginatorMappingRepository = loanOriginatorMappingRepository; } - private Long findOrCreateOriginatorIdByExternalId(final LoanApplicationOriginatorData originatorData) { - try { - return loanOriginatorHelper.findOrCreateOriginatorId(originatorData); - } catch (final JpaSystemException | DataIntegrityViolationException e) { - if (!isConstraintViolation(e)) { - throw e; - } - // Another thread created the originator concurrently - retry - return loanOriginatorHelper.findOrCreateOriginatorId(originatorData); + @Override + protected void createAndSaveOriginatorMapping(Long loanId, Long originatorId) { + if (!loanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId, originatorId)) { + final LoanOriginator originatorRef = loanOriginatorRepository.getReferenceById(originatorId); + final LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originatorRef); + loanOriginatorMappingRepository.save(mapping); + log.debug("Attached originator {} to loan {}", originatorId, loanId); } } - - private boolean isConstraintViolation(final DataAccessException e) { - return e.getMostSpecificCause() instanceof SQLException sqlEx && sqlEx.getSQLState() != null - && sqlEx.getSQLState().startsWith(SQL_STATE_INTEGRITY_CONSTRAINT_VIOLATION); - } } diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorWritePlatformServiceImpl.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorWritePlatformServiceImpl.java index 580adb1fc0d..6093f68925e 100644 --- a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorWritePlatformServiceImpl.java +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorWritePlatformServiceImpl.java @@ -51,9 +51,11 @@ import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotFoundException; import org.apache.fineract.portfolio.loanorigination.serialization.LoanOriginatorDataValidator; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Primary @Service @RequiredArgsConstructor @Transactional diff --git a/fineract-working-capital-loan/dependencies.gradle b/fineract-working-capital-loan/dependencies.gradle index bcf32ae0763..f4312ed7ee1 100644 --- a/fineract-working-capital-loan/dependencies.gradle +++ b/fineract-working-capital-loan/dependencies.gradle @@ -23,6 +23,7 @@ dependencies { implementation(project(path: ':fineract-loan')) implementation(project(path: ':fineract-accounting')) implementation(project(path: ':fineract-cob')) + implementation(project(path: ':fineract-loan-origination')) implementation('org.apache.avro:avro') implementation( project(path: ':fineract-avro-schemas') 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 7e6096d7de0..26c0ca88894 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 @@ -94,4 +94,7 @@ private WorkingCapitalLoanConstants() { // Period payment rate change parameters public static final String periodPaymentRateParamName = "periodPaymentRate"; public static final String previousPeriodPaymentRateParamName = "previousRate"; + + // Loan origination parameters + public static final String originatorsParameterName = "originators"; } 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 439e406d568..b0850a395a0 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 @@ -232,6 +232,31 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public GetBalance balance; @Schema(description = "Working Capital Delinquency Collection Data") public WorkingCapitalCollection collectionData; + @Schema(description = "List of originators associated with this loan") + public List originators; + + @Schema(description = "Originator data associated with the loan") + public static final class GetWorkingCapitalLoansLoanIdOriginatorData { + + private GetWorkingCapitalLoansLoanIdOriginatorData() {} + + @Schema(example = "1") + public Long id; + @Schema(example = "REV-SHARE-001") + public String externalId; + @Schema(example = "PP Merchant") + public String name; + @Schema(example = "ACTIVE") + public String status; + @Schema(example = "1") + public Long originatorTypeId; + @Schema(example = "MERCHANT") + public String originatorTypeName; + @Schema(example = "2") + public Long channelTypeId; + @Schema(example = "ONLINE") + public String channelTypeName; + } } @Schema(description = "Working capital loan running balances") @@ -363,6 +388,12 @@ private PostWorkingCapitalLoansRequest() {} @Schema(example = "0", description = "Number of days to shift the start of the first breach schedule period after disbursement") public Integer breachGraceDays; public List paymentAllocation; + @Schema(description = """ + Optional array of originators to associate with this loan. \ + Each entry can reference an existing originator by 'id' or 'externalId'. \ + If the global config 'enable_originator_creation_during_loan_application' is enabled, \ + non-existing originators will be auto-created using the provided details (name, typeId, channelTypeId).""") + public List originators; @Schema(example = "en_GB") public String locale; @@ -389,6 +420,27 @@ private PostPaymentAllocationOrder() {} @Schema(example = "1") public Integer order; } + + @Schema(description = "Originator data for loan creation request") + public static final class PostWorkingCapitalLoansOriginatorData { + + private PostWorkingCapitalLoansOriginatorData() {} + + @Schema(description = "Originator internal ID (use this OR externalId, not both)", example = "1") + public Long id; + + @Schema(description = "Originator external ID (use this OR id, not both)", example = "REV-SHARE-001") + public String externalId; + + @Schema(description = "Originator name (used when creating new originator if config enabled)", example = "PP Merchant") + public String name; + + @Schema(description = "Code value ID for originator type (from LoanOriginatorType code)", example = "1") + public Long typeId; + + @Schema(description = "Code value ID for channel type (from LoanOriginationChannelType code)", example = "2") + public Long channelTypeId; + } } @Schema(description = "PostWorkingCapitalLoansResponse") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanOriginatorsApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanOriginatorsApiResource.java new file mode 100644 index 00000000000..f03e362c6ad --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanOriginatorsApiResource.java @@ -0,0 +1,280 @@ +/** + * 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.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +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.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorMappingResponse; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorsResponse; +import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanOriginatorReadPlatformService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Path("/v1/working-capital-loans") +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +@Tag(name = "Working Capital Loan Originators", description = "Fetch loan originator details for a specific working capital loan") +public class WorkingCapitalLoanOriginatorsApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = WorkingCapitalLoanConstants.WCL_RESOURCE_NAME; + + private final PlatformSecurityContext context; + private final WorkingCapitalLoanApplicationReadPlatformService workingCapitalLoanReadPlatformService; + private final WorkingCapitalLoanOriginatorReadPlatformService workingCapitalLoanOriginatorReadPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Path("{loanId}/originators") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve originators for a working capital loan by loan ID", description = "Retrieves all originators attached to a specific working capital loan. Requires READ_WORKINGCAPITALLOAN permission.") + @ApiResponse(responseCode = "200", description = "OK - Returns wrapped list of originators (may be empty)", content = @Content(schema = @Schema(implementation = LoanOriginatorsResponse.class))) + @ApiResponse(responseCode = "403", description = "Insufficient permissions") + @ApiResponse(responseCode = "404", description = "Loan not found") + public LoanOriginatorsResponse retrieveOriginatorsByWorkingCapitalLoanId( + @PathParam("loanId") @Parameter(description = "loanId") final Long loanId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + if (!workingCapitalLoanReadPlatformService.existsByLoanId(loanId)) { + throw new WorkingCapitalLoanNotFoundException(loanId); + } + + return LoanOriginatorsResponse.of(this.workingCapitalLoanOriginatorReadPlatformService.retrieveByLoanId(loanId)); + } + + @GET + @Path("external-id/{loanExternalId}/originators") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve originators for a working capital loan by loan external ID", description = "Retrieves all originators attached to a specific working capital loan using loan external ID. Requires READ_WORKINGCAPITALLOAN permission.") + @ApiResponse(responseCode = "200", description = "OK - Returns wrapped list of originators (may be empty)", content = @Content(schema = @Schema(implementation = LoanOriginatorsResponse.class))) + @ApiResponse(responseCode = "403", description = "Insufficient permissions") + @ApiResponse(responseCode = "404", description = "Loan not found") + public LoanOriginatorsResponse retrieveOriginatorsByWorkingCapitalLoanExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final ExternalId externalId = ExternalIdFactory.produce(loanExternalId); + Long resolvedLoanId = this.workingCapitalLoanReadPlatformService.getResolvedLoanId(externalId); + if (resolvedLoanId == null) { + throw new WorkingCapitalLoanNotFoundException(externalId); + } + + return LoanOriginatorsResponse.of(this.workingCapitalLoanOriginatorReadPlatformService.retrieveByLoanId(resolvedLoanId)); + } + + @POST + @Path("{loanId}/originators/{originatorId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Attach originator to working capital loan by IDs", description = "Attaches an originator to a working capital loan. Loan must be in 'Submitted and Pending Approval' status. Requires ATTACH_LOAN_ORIGINATOR permission.") + @ApiResponse(responseCode = "200", description = "OK - Originator attached", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping, or insufficient permissions") + @ApiResponse(responseCode = "404", description = "Loan or originator not found") + public LoanOriginatorMappingResponse attachOriginatorToWorkingCapitalLoan( + @PathParam("loanId") @Parameter(description = "loanId") final Long loanId, + @PathParam("originatorId") @Parameter(description = "originatorId") final Long originatorId) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().attachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return buildMappingResponse(result); + } + + @POST + @Path("{loanId}/originators/external-id/{originatorExternalId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Attach originator to working capital loan by loan ID and originator external ID") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping") + @ApiResponse(responseCode = "404", description = "Loan or originator not found") + public LoanOriginatorMappingResponse attachOriginatorToWorkingCapitalLoanByOriginatorExternalId( + @PathParam("loanId") @Parameter(description = "loanId") final Long loanId, + @PathParam("originatorExternalId") @Parameter(description = "originatorExternalId") final String originatorExternalId) { + + final Long originatorId = this.workingCapitalLoanOriginatorReadPlatformService.resolveIdByExternalId(originatorExternalId); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().attachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + @POST + @Path("external-id/{loanExternalId}/originators/{originatorId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Attach originator to working capital loan by loan external ID and originator ID") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping") + @ApiResponse(responseCode = "404", description = "Loan or originator not found") + public LoanOriginatorMappingResponse attachOriginatorToWorkingCapitalLoanByLoanExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, + @PathParam("originatorId") @Parameter(description = "originatorId") final Long originatorId) { + + final ExternalId externalId = ExternalIdFactory.produce(loanExternalId); + final Long loanId = this.workingCapitalLoanReadPlatformService.getResolvedLoanId(externalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(externalId); + } + + final CommandWrapper commandRequest = new CommandWrapperBuilder().attachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + @POST + @Path("external-id/{loanExternalId}/originators/external-id/{originatorExternalId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Attach originator to working capital loan by loan external ID and originator external ID") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status, originator not ACTIVE, duplicate mapping") + @ApiResponse(responseCode = "404", description = "Loan or originator not found") + public LoanOriginatorMappingResponse attachOriginatorToWorkingCapitalLoanByBothExternalIds( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, + @PathParam("originatorExternalId") @Parameter(description = "originatorExternalId") final String originatorExternalId) { + + final ExternalId loanExtId = ExternalIdFactory.produce(loanExternalId); + final Long loanId = this.workingCapitalLoanReadPlatformService.getResolvedLoanId(loanExtId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExtId); + } + + final Long originatorId = this.workingCapitalLoanOriginatorReadPlatformService.resolveIdByExternalId(originatorExternalId); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().attachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + @DELETE + @Path("{loanId}/originators/{originatorId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Detach originator from working capital loan by IDs", description = "Detaches an originator from a working capital loan. Loan must be in 'Submitted and Pending Approval' status. Requires DETACH_LOAN_ORIGINATOR permission.") + @ApiResponse(responseCode = "200", description = "OK - Originator detached", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status or insufficient permissions") + @ApiResponse(responseCode = "404", description = "Loan or originator mapping not found") + public LoanOriginatorMappingResponse detachOriginatorFromWorkingCapitalLoan( + @PathParam("loanId") @Parameter(description = "loanId") final Long loanId, + @PathParam("originatorId") @Parameter(description = "originatorId") final Long originatorId) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder().detachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + @DELETE + @Path("{loanId}/originators/external-id/{originatorExternalId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Detach originator from working capital loan by loan ID and originator external ID") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status") + @ApiResponse(responseCode = "404", description = "Loan or originator mapping not found") + public LoanOriginatorMappingResponse detachOriginatorFromWorkingCapitalLoanByOriginatorExternalId( + @PathParam("loanId") @Parameter(description = "loanId") final Long loanId, + @PathParam("originatorExternalId") @Parameter(description = "originatorExternalId") final String originatorExternalId) { + + final Long originatorId = this.workingCapitalLoanOriginatorReadPlatformService.resolveIdByExternalId(originatorExternalId); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().detachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + @DELETE + @Path("external-id/{loanExternalId}/originators/{originatorId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Detach originator from working capital loan by loan external ID and originator ID") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status") + @ApiResponse(responseCode = "404", description = "Loan or originator mapping not found") + public LoanOriginatorMappingResponse detachOriginatorFromWorkingCapitalLoanByLoanExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, + @PathParam("originatorId") @Parameter(description = "originatorId") final Long originatorId) { + + final ExternalId externalId = ExternalIdFactory.produce(loanExternalId); + final Long loanId = this.workingCapitalLoanReadPlatformService.getResolvedLoanId(externalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(externalId); + } + + final CommandWrapper commandRequest = new CommandWrapperBuilder().detachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + @DELETE + @Path("external-id/{loanExternalId}/originators/external-id/{originatorExternalId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Detach originator from working capital loan by loan external ID and originator external ID") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanOriginatorMappingResponse.class))) + @ApiResponse(responseCode = "403", description = "Loan not in correct status") + @ApiResponse(responseCode = "404", description = "Loan or originator mapping not found") + public LoanOriginatorMappingResponse detachOriginatorFromWorkingCapitalLoanByBothExternalIds( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId") final String loanExternalId, + @PathParam("originatorExternalId") @Parameter(description = "originatorExternalId") final String originatorExternalId) { + + final ExternalId loanExtId = ExternalIdFactory.produce(loanExternalId); + final Long loanId = this.workingCapitalLoanReadPlatformService.getResolvedLoanId(loanExtId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExtId); + } + + final Long originatorId = this.workingCapitalLoanOriginatorReadPlatformService.resolveIdByExternalId(originatorExternalId); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().detachWorkingCapitalLoanOriginator(loanId, originatorId).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return buildMappingResponse(result); + } + + private LoanOriginatorMappingResponse buildMappingResponse(final CommandProcessingResult result) { + return LoanOriginatorMappingResponse.of(result.getResourceId(), + result.getResourceExternalId() != null ? result.getResourceExternalId().getValue() : null, result.getSubResourceId(), + result.getSubResourceExternalId() != null ? result.getSubResourceExternalId().getValue() : null); + } +} 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 7fa66cd9df7..8f3fa153db3 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 @@ -34,6 +34,7 @@ import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData; import org.apache.fineract.portfolio.loanaccount.data.LoanApplicationTimelineData; import org.apache.fineract.portfolio.loanaccount.data.LoanStatusEnumData; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorData; import org.apache.fineract.portfolio.workingcapitalloanbreach.data.WorkingCapitalBreachData; import org.apache.fineract.portfolio.workingcapitalloannearbreach.data.WorkingCapitalNearBreachData; import org.apache.fineract.portfolio.workingcapitalloanproduct.data.WorkingCapitalLoanProductData; @@ -91,4 +92,5 @@ public class WorkingCapitalLoanData implements Serializable { private WorkingCapitalLoanCollectionData collectionData; private WorkingCapitalLoanSummaryData summary; + private List originators; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanNotInSubmittedStatusException.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanNotInSubmittedStatusException.java new file mode 100644 index 00000000000..306fb00f25b --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanNotInSubmittedStatusException.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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class WorkingCapitalLoanNotInSubmittedStatusException extends AbstractPlatformDomainRuleException { + + public WorkingCapitalLoanNotInSubmittedStatusException(Long loanId, String currentStatus) { + super("error.msg.wc.loan.not.in.submitted.status", + "Working Capital Loan with id " + loanId + " has status " + currentStatus + + ". Originator can only be attached/detached while loan is in 'Submitted and Pending Approval' status.", + loanId, currentStatus); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/AttachWorkingCapitalLoanOriginatorCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/AttachWorkingCapitalLoanOriginatorCommandHandler.java new file mode 100644 index 00000000000..6160921560e --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/AttachWorkingCapitalLoanOriginatorCommandHandler.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.WorkingCapitalLoanOriginatorWritePlatformService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@CommandType(entity = "WORKING_CAPITAL_LOAN_ORIGINATOR", action = "ATTACH") +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class AttachWorkingCapitalLoanOriginatorCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanOriginatorWritePlatformService writePlatformService; + + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.attachOriginatorToLoan(command.getLoanId(), command.subentityId()); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DetachWorkingCapitalLoanOriginatorCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DetachWorkingCapitalLoanOriginatorCommandHandler.java new file mode 100644 index 00000000000..6ea440f5bd5 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DetachWorkingCapitalLoanOriginatorCommandHandler.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.WorkingCapitalLoanOriginatorWritePlatformService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@CommandType(entity = "WORKING_CAPITAL_LOAN_ORIGINATOR", action = "DETACH") +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class DetachWorkingCapitalLoanOriginatorCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanOriginatorWritePlatformService writePlatformService; + + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.detachOriginatorFromLoan(command.getLoanId(), command.subentityId()); + } +} 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 7b4ef79faa5..b00a54b3603 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 @@ -80,6 +80,7 @@ public interface WorkingCapitalLoanMapper { @Mapping(target = "calculatedAnnualEir", ignore = true) @Mapping(target = "summary", source = ".", qualifiedByName = "toSummaryData") @Mapping(target = "totalPaymentVolume", source = "totalPaymentVolume") + @Mapping(target = "originators", ignore = true) WorkingCapitalLoanData toData(WorkingCapitalLoan loan); List toDataList(List loans); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java index 97b77e6e01a..770806a436d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java @@ -86,9 +86,9 @@ public class WorkingCapitalLoanApplicationDataValidator { WorkingCapitalLoanConstants.clientIdParameterName, WorkingCapitalLoanConstants.productIdParameterName, WorkingCapitalLoanConstants.fundIdParameterName, WorkingCapitalLoanConstants.accountNoParameterName, WorkingCapitalLoanConstants.externalIdParameterName, WorkingCapitalLoanConstants.principalAmountParamName, - WorkingCapitalLoanProductConstants.periodPaymentRateParamName, WorkingCapitalLoanConstants.totalPaymentVolumeParamName, - WorkingCapitalLoanProductConstants.discountParamName, WorkingCapitalLoanConstants.submittedOnDateParameterName, - WorkingCapitalLoanConstants.expectedDisbursementDateParameterName, + WorkingCapitalLoanConstants.originatorsParameterName, WorkingCapitalLoanProductConstants.periodPaymentRateParamName, + WorkingCapitalLoanConstants.totalPaymentVolumeParamName, WorkingCapitalLoanProductConstants.discountParamName, + WorkingCapitalLoanConstants.submittedOnDateParameterName, WorkingCapitalLoanConstants.expectedDisbursementDateParameterName, WorkingCapitalLoanProductConstants.delinquencyBucketIdParamName, WorkingCapitalLoanProductConstants.repaymentEveryParamName, WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, WorkingCapitalLoanConstants.submittedOnNoteParameterName, WorkingCapitalLoanProductConstants.breachIdParamName, WorkingCapitalLoanProductConstants.allowAttributeOverridesParamName, diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformService.java index ff059202191..9e94c6461ca 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformService.java @@ -53,4 +53,9 @@ public interface WorkingCapitalLoanApplicationReadPlatformService { * Retrieves Working Capital Loan Summary Data based on the Client Id */ List retrieveLoanSummaryData(Long clientId); + + /** + * Checks if a Working Capital Loan exists with the given Id + */ + boolean existsByLoanId(Long loanId); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java index b047dccaa70..274770cce88 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java @@ -23,9 +23,11 @@ import java.math.MathContext; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -40,6 +42,7 @@ import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyMinimumPaymentType; import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformService; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCollectionData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTemplateData; @@ -78,6 +81,7 @@ public class WorkingCapitalLoanApplicationReadPlatformServiceImpl implements Wor private final WorkingCapitalLoanDelinquencyReadPlatformService workingCapitalLoanDelinquencyReadPlatformService; private final WorkingCapitalNearBreachReadPlatformService nearBreachReadPlatformService; private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper; + private final Optional originatorReadService; @Override public WorkingCapitalLoanTemplateData retrieveTemplate(final Long productId, final Long clientId) { @@ -169,6 +173,7 @@ public WorkingCapitalLoanData retrieveOne(final Long loanId) { ThreadLocalContextUtil.getBusinessDate()); data.setCollectionData(collectionData); enrichWithRateAndTerm(loan, data); + enrichWithOriginators(loanId, data); return data; } @@ -191,6 +196,13 @@ private void enrichWithRateAndTerm(final WorkingCapitalLoan loan, final WorkingC }); } + private void enrichWithOriginators(final Long loanId, final WorkingCapitalLoanData data) { + if (this.originatorReadService.isPresent()) { + List loanOriginatorData = this.originatorReadService.get().retrieveByLoanId(loanId); + data.setOriginators(loanOriginatorData.isEmpty() ? Collections.emptyList() : loanOriginatorData); + } + } + @Override public Long getResolvedLoanId(final ExternalId externalId) { return this.repository.findByExternalId(externalId).map(WorkingCapitalLoan::getId).orElse(null); @@ -200,4 +212,9 @@ public Long getResolvedLoanId(final ExternalId externalId) { public List retrieveLoanSummaryData(final Long clientId) { return workingCapitalLoanSummaryMapper.toDataList(repository.findByClient_Id(clientId)); } + + @Override + public boolean existsByLoanId(Long loanId) { + return this.repository.existsById(loanId); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java index 7be2aa9f233..191d025b6b2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java @@ -18,16 +18,18 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import com.google.gson.JsonArray; import jakarta.persistence.PersistenceException; import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; +import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; 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.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; @@ -37,13 +39,13 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanApplicationDataValidator; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service -@RequiredArgsConstructor public class WorkingCapitalLoanApplicationWritePlatformServiceImpl implements WorkingCapitalLoanApplicationWritePlatformService { private final WorkingCapitalLoanApplicationDataValidator validator; @@ -51,6 +53,19 @@ public class WorkingCapitalLoanApplicationWritePlatformServiceImpl implements Wo private final WorkingCapitalLoanAssembler assembler; private final WorkingCapitalLoanNoteRepository noteRepository; private final ProjectedAmortizationLoanModelRepository projectedAmortizationLoanModelRepository; + private final Optional loanOriginatorLinkingService; + + public WorkingCapitalLoanApplicationWritePlatformServiceImpl(WorkingCapitalLoanApplicationDataValidator validator, + WorkingCapitalLoanRepository repository, WorkingCapitalLoanAssembler assembler, WorkingCapitalLoanNoteRepository noteRepository, + ProjectedAmortizationLoanModelRepository projectedAmortizationLoanModelRepository, + @Qualifier("workingCapitalLoanOriginatorLinkingServiceImpl") Optional loanOriginatorLinkingService) { + this.validator = validator; + this.repository = repository; + this.assembler = assembler; + this.noteRepository = noteRepository; + this.projectedAmortizationLoanModelRepository = projectedAmortizationLoanModelRepository; + this.loanOriginatorLinkingService = loanOriginatorLinkingService; + } @Transactional @Override @@ -63,6 +78,7 @@ public CommandProcessingResult submitApplication(final JsonCommand command) { this.repository.saveAndFlush(saved); final String submittedOnNote = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.submittedOnNoteParameterName); createNote(submittedOnNote, saved); + attachOriginatorsIfProvided(command, saved); return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // @@ -146,4 +162,14 @@ private void createNote(final String submittedOnNote, final WorkingCapitalLoan l this.noteRepository.save(note); } } + + private void attachOriginatorsIfProvided(final JsonCommand command, final WorkingCapitalLoan loan) { + if (this.loanOriginatorLinkingService.isPresent() + && command.parameterExists(WorkingCapitalLoanConstants.originatorsParameterName)) { + final JsonArray originatorsArray = command.arrayOfParameterNamed(WorkingCapitalLoanConstants.originatorsParameterName); + if (originatorsArray != null && !originatorsArray.isEmpty()) { + this.loanOriginatorLinkingService.get().processOriginatorsForLoanApplication(loan.getId(), originatorsArray); + } + } + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorLinkingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorLinkingServiceImpl.java new file mode 100644 index 00000000000..cdaac4ac7b2 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorLinkingServiceImpl.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.service; + +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; +import org.apache.fineract.portfolio.loanorigination.domain.WorkingCapitalLoanOriginatorMapping; +import org.apache.fineract.portfolio.loanorigination.domain.WorkingCapitalLoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.serialization.LoanApplicationOriginatorDataValidator; +import org.apache.fineract.portfolio.loanorigination.service.AbstractLoanOriginatorLinkingServiceImpl; +import org.apache.fineract.portfolio.loanorigination.service.LoanOriginatorHelper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * Implementation of {@link LoanOriginatorLinkingService} that handles processing of originators during loan + * application. This service is active only when the loan-origination module is enabled. + */ +@Slf4j +@Service("workingCapitalLoanOriginatorLinkingServiceImpl") +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class WorkingCapitalLoanOriginatorLinkingServiceImpl extends AbstractLoanOriginatorLinkingServiceImpl { + + private final WorkingCapitalLoanOriginatorMappingRepository loanOriginatorMappingRepository; + + public WorkingCapitalLoanOriginatorLinkingServiceImpl(LoanOriginatorRepository loanOriginatorRepository, + LoanApplicationOriginatorDataValidator validator, LoanOriginatorHelper loanOriginatorHelper, + WorkingCapitalLoanOriginatorMappingRepository loanOriginatorMappingRepository) { + super(loanOriginatorRepository, validator, loanOriginatorHelper); + this.loanOriginatorMappingRepository = loanOriginatorMappingRepository; + } + + @Override + protected void createAndSaveOriginatorMapping(Long loanId, Long originatorId) { + if (!loanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId, originatorId)) { + final LoanOriginator originatorRef = loanOriginatorRepository.getReferenceById(originatorId); + final WorkingCapitalLoanOriginatorMapping mapping = WorkingCapitalLoanOriginatorMapping.create(loanId, originatorRef); + loanOriginatorMappingRepository.save(mapping); + log.debug("Attached originator {} to working capital loan {}", originatorId, loanId); + } + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorReadPlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorReadPlatformService.java new file mode 100644 index 00000000000..8d64a762eef --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorReadPlatformService.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.service; + +import java.util.List; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorData; + +public interface WorkingCapitalLoanOriginatorReadPlatformService { + + List retrieveByLoanId(Long loanId); + + Long resolveIdByExternalId(String externalId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorReadPlatformServiceImpl.java new file mode 100644 index 00000000000..31177611501 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorReadPlatformServiceImpl.java @@ -0,0 +1,61 @@ +/** + * 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.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorData; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginator; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; +import org.apache.fineract.portfolio.loanorigination.domain.WorkingCapitalLoanOriginatorMapping; +import org.apache.fineract.portfolio.loanorigination.domain.WorkingCapitalLoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotFoundException; +import org.apache.fineract.portfolio.loanorigination.mapper.LoanOriginatorMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class WorkingCapitalLoanOriginatorReadPlatformServiceImpl implements WorkingCapitalLoanOriginatorReadPlatformService { + + private final WorkingCapitalLoanOriginatorMappingRepository loanOriginatorMappingRepository; + private final LoanOriginatorRepository loanOriginatorRepository; + private final LoanOriginatorMapper loanOriginatorMapper; + + @Override + public List retrieveByLoanId(final Long loanId) { + final List mappings = this.loanOriginatorMappingRepository.findByLoanIdWithOriginator(loanId); + if (mappings.isEmpty()) { + return Collections.emptyList(); + } + return mappings.stream().map(WorkingCapitalLoanOriginatorMapping::getOriginator).map(this.loanOriginatorMapper::toData).toList(); + } + + @Override + public Long resolveIdByExternalId(final String externalId) { + final LoanOriginator originator = this.loanOriginatorRepository.findByExternalId(new ExternalId(externalId)) + .orElseThrow(() -> new LoanOriginatorNotFoundException(externalId)); + return originator.getId(); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorWritePlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorWritePlatformService.java new file mode 100644 index 00000000000..4143d715e1e --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanOriginatorWritePlatformService.java @@ -0,0 +1,125 @@ +/** + * 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 lombok.RequiredArgsConstructor; +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.portfolio.loanorigination.domain.LoanOriginator; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorRepository; +import org.apache.fineract.portfolio.loanorigination.domain.LoanOriginatorStatus; +import org.apache.fineract.portfolio.loanorigination.domain.WorkingCapitalLoanOriginatorMapping; +import org.apache.fineract.portfolio.loanorigination.domain.WorkingCapitalLoanOriginatorMappingRepository; +import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorMappingAlreadyExistsException; +import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotActiveException; +import org.apache.fineract.portfolio.loanorigination.exception.LoanOriginatorNotFoundException; +import org.apache.fineract.portfolio.loanorigination.exception.WorkingCapitalLoanOriginatorMappingNotFoundException; +import org.apache.fineract.portfolio.loanorigination.service.LoanOriginatorWritePlatformService; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanApplicationNotInSubmittedStateCannotBeDeletedException; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotInSubmittedStatusException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class WorkingCapitalLoanOriginatorWritePlatformService implements LoanOriginatorWritePlatformService { + + private final WorkingCapitalLoanRepository workingCapitalLoanRepository; + private final LoanOriginatorRepository loanOriginatorRepository; + private final WorkingCapitalLoanOriginatorMappingRepository workingCapitalLoanOriginatorMappingRepository; + + @Override + public CommandProcessingResult create(final JsonCommand command) { + throw new UnsupportedOperationException("Use LoanOriginatorWritePlatformService for originator creation"); + } + + @Override + public CommandProcessingResult update(final Long id, final JsonCommand command) { + throw new UnsupportedOperationException("Use LoanOriginatorWritePlatformService for originator updates"); + } + + @Override + public CommandProcessingResult delete(final Long id) { + throw new UnsupportedOperationException("Use LoanOriginatorWritePlatformService for originator deletion"); + } + + @Override + public CommandProcessingResult attachOriginatorToLoan(final Long loanId, final Long originatorId) { + final WorkingCapitalLoan loan = this.workingCapitalLoanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + if (!loan.getLoanStatus().isSubmittedAndPendingApproval()) { + throw new WorkingCapitalLoanNotInSubmittedStatusException(loanId, loan.getLoanStatus().getCode()); + } + + final LoanOriginator originator = this.loanOriginatorRepository.findById(originatorId) + .orElseThrow(() -> new LoanOriginatorNotFoundException(originatorId)); + + if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) { + throw new LoanOriginatorNotActiveException(originatorId, originator.getStatus().getValue()); + } + + if (this.workingCapitalLoanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId, originatorId)) { + throw new LoanOriginatorMappingAlreadyExistsException(loanId, originatorId); + } + + final WorkingCapitalLoanOriginatorMapping mapping = WorkingCapitalLoanOriginatorMapping.create(loanId, originator); + this.workingCapitalLoanOriginatorMappingRepository.saveAndFlush(mapping); + + return new CommandProcessingResultBuilder() // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withSubEntityId(originatorId) // + .withSubEntityExternalId(originator.getExternalId()) // + .build(); + } + + @Override + public CommandProcessingResult detachOriginatorFromLoan(final Long loanId, final Long originatorId) { + final WorkingCapitalLoan loan = this.workingCapitalLoanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + if (!loan.getLoanStatus().isSubmittedAndPendingApproval()) { + throw new WorkingCapitalLoanApplicationNotInSubmittedStateCannotBeDeletedException(loanId); + } + + final LoanOriginator originator = this.loanOriginatorRepository.findById(originatorId) + .orElseThrow(() -> new LoanOriginatorNotFoundException(originatorId)); + + final WorkingCapitalLoanOriginatorMapping mapping = this.workingCapitalLoanOriginatorMappingRepository + .findByLoanIdAndOriginatorId(loanId, originatorId) + .orElseThrow(() -> new WorkingCapitalLoanOriginatorMappingNotFoundException(loanId, originatorId)); + + this.workingCapitalLoanOriginatorMappingRepository.delete(mapping); + + return new CommandProcessingResultBuilder() // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withSubEntityId(originatorId) // + .withSubEntityExternalId(originator.getExternalId()) // + .build(); + } +} 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 25190c291cb..cd395076b1b 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 @@ -65,4 +65,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_originator_mapping.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_originator_mapping.xml new file mode 100644 index 00000000000..770788da85f --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0044_wc_loan_originator_mapping.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanOriginatorTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanOriginatorTest.java new file mode 100644 index 00000000000..990204c9682 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanOriginatorTest.java @@ -0,0 +1,255 @@ +/** + * 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.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.LoanOriginatorData; +import org.apache.fineract.client.models.LoanOriginatorsResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoansOriginatorData; +import org.apache.fineract.integrationtests.client.feign.helpers.WorkingCapitalLoanOriginatorHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.Test; + +public class WorkingCapitalLoanOriginatorTest { + + private final WorkingCapitalLoanHelper wcLoanHelper = new WorkingCapitalLoanHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + private final WorkingCapitalLoanOriginatorHelper originatorHelper = new WorkingCapitalLoanOriginatorHelper(); + + @Test + public void testCreateWorkingCapitalLoanWithOriginator() { + final String originatorExternalId = Utils.randomStringGenerator("originator", 20); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId, "Test Originator"); + final Long productId = createProduct(); + final Long clientId = createClient(); + + final var json = new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(clientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .withTotalPaymentVolume(BigDecimal.valueOf(5500)) // + .withOriginators(List.of(new PostWorkingCapitalLoansOriginatorData().id(originatorId))) // + .buildSubmitRequest(); + + final Long loanId = wcLoanHelper.submit(json); + assertNotNull(loanId); + assertTrue(loanId > 0); + + final GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.retrieveById(loanId); + assertNotNull(loanDetails); + assertNotNull(loanDetails.getOriginators()); + assertThat(loanDetails.getOriginators()).hasSize(1); + assertEquals(originatorId.longValue(), loanDetails.getOriginators().get(0).getId()); + assertEquals(originatorExternalId, loanDetails.getOriginators().get(0).getExternalId()); + + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId); + wcLoanHelper.deleteById(loanId); + productHelper.deleteWorkingCapitalLoanProductById(productId); + originatorHelper.deleteOriginator(originatorId); + } + + @Test + public void testCreateWorkingCapitalLoanWithMultipleOriginators() { + final String originatorExternalId1 = Utils.randomStringGenerator("originator", 20); + final String originatorExternalId2 = Utils.randomStringGenerator("originator", 20); + final Long originatorId1 = originatorHelper.createOriginator(originatorExternalId1, "Originator 1"); + final Long originatorId2 = originatorHelper.createOriginator(originatorExternalId2, "Originator 2"); + final Long productId = createProduct(); + final Long clientId = createClient(); + + final var json = new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(clientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .withTotalPaymentVolume(BigDecimal.valueOf(5500)) // + .withOriginators(List.of(new PostWorkingCapitalLoansOriginatorData().id(originatorId1), + new PostWorkingCapitalLoansOriginatorData().id(originatorId2))) // + .buildSubmitRequest(); + + final Long loanId = wcLoanHelper.submit(json); + assertNotNull(loanId); + assertTrue(loanId > 0); + + final GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.retrieveById(loanId); + assertNotNull(loanDetails); + assertNotNull(loanDetails.getOriginators()); + assertThat(loanDetails.getOriginators()).hasSize(2); + + Set originatorIds = new HashSet<>(); + Set originatorExternalIds = new HashSet<>(); + loanDetails.getOriginators().forEach(originator -> { + originatorIds.add(originator.getId()); + originatorExternalIds.add(originator.getExternalId()); + }); + + assertThat(originatorIds).containsExactlyInAnyOrder(originatorId1, originatorId2); + assertThat(originatorExternalIds).containsExactlyInAnyOrder(originatorExternalId1, originatorExternalId2); + + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId2); + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId1); + wcLoanHelper.deleteById(loanId); + productHelper.deleteWorkingCapitalLoanProductById(productId); + originatorHelper.deleteOriginator(originatorId2); + originatorHelper.deleteOriginator(originatorId1); + } + + @Test + public void testCreateWorkingCapitalLoanWithoutOriginatorAndAttachLater() { + final String originatorExternalId = Utils.randomStringGenerator("originator", 20); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId, "Test Originator"); + final Long productId = createProduct(); + final Long clientId = createClient(); + + final var json = new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(clientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .withTotalPaymentVolume(BigDecimal.valueOf(5500)) // + .buildSubmitRequest(); + + final Long loanId = wcLoanHelper.submit(json); + assertNotNull(loanId); + assertTrue(loanId > 0); + + originatorHelper.attachOriginatorToWorkingCapitalLoan(loanId, originatorId); + + final GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.retrieveById(loanId); + assertNotNull(loanDetails); + assertNotNull(loanDetails.getOriginators()); + assertThat(loanDetails.getOriginators()).hasSize(1); + assertEquals(originatorId.longValue(), loanDetails.getOriginators().get(0).getId()); + assertEquals(originatorExternalId, loanDetails.getOriginators().get(0).getExternalId()); + + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId); + wcLoanHelper.deleteById(loanId); + productHelper.deleteWorkingCapitalLoanProductById(productId); + originatorHelper.deleteOriginator(originatorId); + } + + @Test + public void testRetrieveWorkingCapitalLoanOriginators() { + final String originatorExternalId1 = Utils.randomStringGenerator("originator", 20); + final String originatorExternalId2 = Utils.randomStringGenerator("originator", 20); + final Long originatorId1 = originatorHelper.createOriginator(originatorExternalId1, "Originator 1"); + final Long originatorId2 = originatorHelper.createOriginator(originatorExternalId2, "Originator 2"); + final Long productId = createProduct(); + final Long clientId = createClient(); + + final var json = new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(clientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .withTotalPaymentVolume(BigDecimal.valueOf(5500)) // + .withOriginators(List.of(new PostWorkingCapitalLoansOriginatorData().id(originatorId1))) // + .buildSubmitRequest(); + + final Long loanId = wcLoanHelper.submit(json); + assertNotNull(loanId); + + originatorHelper.attachOriginatorToWorkingCapitalLoan(loanId, originatorId2); + + LoanOriginatorsResponse loanOriginatorsResponse = originatorHelper.retrieveOriginatorsByWorkingCapitalLoanId(loanId); + assertNotNull(loanOriginatorsResponse); + assertNotNull(loanOriginatorsResponse.getOriginators()); + assertThat(loanOriginatorsResponse.getOriginators()).hasSize(2); + + Set actualLoanOriginators = loanOriginatorsResponse.getOriginators().stream().map(LoanOriginatorData::getId) + .collect(Collectors.toSet()); + assertThat(actualLoanOriginators).containsExactlyInAnyOrder(originatorId1, originatorId2); + + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId1); + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId2); + wcLoanHelper.deleteById(loanId); + productHelper.deleteWorkingCapitalLoanProductById(productId); + originatorHelper.deleteOriginator(originatorId2); + originatorHelper.deleteOriginator(originatorId1); + } + + @Test + public void testDetachOriginatorFromWorkingCapitalLoan() { + final String originatorExternalId = Utils.randomStringGenerator("originator", 20); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId, "Test Originator"); + final Long productId = createProduct(); + final Long clientId = createClient(); + + final var json = new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(clientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .withTotalPaymentVolume(BigDecimal.valueOf(5500)) // + .withOriginators(List.of(new PostWorkingCapitalLoansOriginatorData().id(originatorId))) // + .buildSubmitRequest(); + + final Long loanId = wcLoanHelper.submit(json); + assertNotNull(loanId); + + GetWorkingCapitalLoansLoanIdResponse loanDetails = wcLoanHelper.retrieveById(loanId); + assertThat(loanDetails.getOriginators()).hasSize(1); + + originatorHelper.detachOriginatorFromWorkingCapitalLoan(loanId, originatorId); + + loanDetails = wcLoanHelper.retrieveById(loanId); + assertThat(loanDetails.getOriginators()).isEmpty(); + + wcLoanHelper.deleteById(loanId); + productHelper.deleteWorkingCapitalLoanProductById(productId); + originatorHelper.deleteOriginator(originatorId); + } + + private Long createProduct() { + final String uniqueName = "WCL Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = Utils.uniqueRandomStringGenerator("", 4); + return productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder() // + .withName(uniqueName) // + .withShortName(uniqueShortName) // + .withPrincipalAmountMin(BigDecimal.valueOf(1000)) // + .withPrincipalAmountMax(BigDecimal.valueOf(50000)) // + .withPrincipalAmountDefault(BigDecimal.valueOf(10000)) // + .withMinPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_MIN_PERIOD_PAYMENT_RATE_PERCENT) // + .withMaxPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_MAX_PERIOD_PAYMENT_RATE_PERCENT) // + .withPeriodPaymentRate(WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT) // + .build()) // + .getResourceId(); + } + + private Long createClient() { + return ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/WorkingCapitalLoanOriginatorHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/WorkingCapitalLoanOriginatorHelper.java new file mode 100644 index 00000000000..e4016adc046 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/WorkingCapitalLoanOriginatorHelper.java @@ -0,0 +1,67 @@ +/** + * 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.client.feign.helpers; + +import org.apache.fineract.client.feign.services.LoanOriginatorsApi; +import org.apache.fineract.client.feign.services.WorkingCapitalLoanOriginatorsApi; +import org.apache.fineract.client.feign.util.FeignCalls; +import org.apache.fineract.client.models.LoanOriginatorMappingResponse; +import org.apache.fineract.client.models.LoanOriginatorsResponse; +import org.apache.fineract.client.models.PostLoanOriginatorsRequest; +import org.apache.fineract.client.models.PostLoanOriginatorsResponse; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; + +public class WorkingCapitalLoanOriginatorHelper { + + private static LoanOriginatorsApi api() { + return FineractFeignClientHelper.getFineractFeignClient().loanOriginators(); + } + + private static WorkingCapitalLoanOriginatorsApi workingCapitalLoanOriginatorsApi() { + return FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanOriginators(); + } + + private static final String WORKING_CAPITAL_LOAN_ORIGINATOR_API_URL = "/fineract-provider/api/v1/working-capital-loans"; + + public Long createOriginator(final String externalId, final String name) { + PostLoanOriginatorsRequest request = new PostLoanOriginatorsRequest(); + request.setExternalId(externalId); + request.setName(name); + request.setStatus("ACTIVE"); + + PostLoanOriginatorsResponse response = FeignCalls.ok(() -> api().createLoanOriginator(request)); + return response.getResourceId(); + } + + public void deleteOriginator(final Long originatorId) { + FeignCalls.ok(() -> api().deleteLoanOriginator(originatorId)); + } + + public LoanOriginatorMappingResponse attachOriginatorToWorkingCapitalLoan(final Long loanId, final Long originatorId) { + return FeignCalls.ok(() -> workingCapitalLoanOriginatorsApi().attachOriginatorToWorkingCapitalLoan(loanId, originatorId)); + } + + public LoanOriginatorMappingResponse detachOriginatorFromWorkingCapitalLoan(final Long loanId, final Long originatorId) { + return FeignCalls.ok(() -> workingCapitalLoanOriginatorsApi().detachOriginatorFromWorkingCapitalLoan(loanId, originatorId)); + } + + public LoanOriginatorsResponse retrieveOriginatorsByWorkingCapitalLoanId(final Long loanId) { + return FeignCalls.ok(() -> workingCapitalLoanOriginatorsApi().retrieveOriginatorsByWorkingCapitalLoanId(loanId)); + } +} 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 285c6dfba4f..3b629ee639d 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 @@ -27,6 +27,7 @@ import org.apache.fineract.client.models.PostPaymentAllocationOrder; import org.apache.fineract.client.models.PostPaymentAllocationRule; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansOriginatorData; import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; @@ -59,6 +60,7 @@ public class WorkingCapitalLoanApplicationTestBuilder { private Integer delinquencyGraceDays; private String delinquencyStartType; private Integer breachGraceDays; + private List originators; public WorkingCapitalLoanApplicationTestBuilder withClientId(final Long clientId) { this.clientId = clientId; @@ -165,6 +167,11 @@ public WorkingCapitalLoanApplicationTestBuilder withPaymentAllocationTypes(final return this; } + public WorkingCapitalLoanApplicationTestBuilder withOriginators(final List originators) { + this.originators = originators; + return this; + } + public PostWorkingCapitalLoansRequest buildSubmitRequest() { return populateSubmitRequest(new PostWorkingCapitalLoansRequest()) .totalPaymentVolume(totalPaymentVolume != null ? totalPaymentVolume : principal) @@ -224,6 +231,9 @@ private PostWorkingCapitalLoansRequest populateSubmitRequest(final PostWorkingCa if (paymentAllocationTypes != null && !paymentAllocationTypes.isEmpty()) { request.paymentAllocation(buildPaymentAllocationRules()); } + if (originators != null && !originators.isEmpty()) { + request.originators(originators); + } return request; }