From 99861ce93ac994facae3262a22270da83da0a4b6 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Wed, 4 Feb 2026 13:25:33 +0100 Subject: [PATCH] FINERACT-2418: add originator during the loan application --- .../data/LoanApplicationOriginatorData.java | 35 +++ ...OriginatorCreationNotAllowedException.java | 30 +++ ...oanApplicationOriginatorDataValidator.java | 120 ++++++++++ .../LoanOriginatorLinkingServiceImpl.java | 158 +++++++++++++ .../service/LoanOriginatorLinkingService.java | 35 +++ .../LoanOriginatorLinkingServiceNoOp.java | 37 +++ ...WritePlatformServiceJpaRepositoryImpl.java | 8 + .../starter/LoanAccountConfiguration.java | 6 +- .../savings/SavingsImportHandlerTest.java | 21 +- .../FeignGlobalConfigurationHelper.java | 61 +++++ .../client/feign/helpers/FeignLoanHelper.java | 43 ++++ ...gnLoanOriginatorDuringApplicationTest.java | 218 ++++++++++++++++++ 12 files changed, 767 insertions(+), 5 deletions(-) create mode 100644 fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/data/LoanApplicationOriginatorData.java create mode 100644 fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/LoanOriginatorCreationNotAllowedException.java create mode 100644 fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/serialization/LoanApplicationOriginatorDataValidator.java create mode 100644 fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingService.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingServiceNoOp.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/data/LoanApplicationOriginatorData.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/data/LoanApplicationOriginatorData.java new file mode 100644 index 000000000..3770d51e4 --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/data/LoanApplicationOriginatorData.java @@ -0,0 +1,35 @@ +/** + * 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.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoanApplicationOriginatorData { + + private Long id; + private String externalId; + private String name; + private Long typeId; + private Long channelTypeId; +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/LoanOriginatorCreationNotAllowedException.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/LoanOriginatorCreationNotAllowedException.java new file mode 100644 index 000000000..7d4460931 --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/exception/LoanOriginatorCreationNotAllowedException.java @@ -0,0 +1,30 @@ +/** + * 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.AbstractPlatformDomainRuleException; + +public class LoanOriginatorCreationNotAllowedException extends AbstractPlatformDomainRuleException { + + public LoanOriginatorCreationNotAllowedException(String externalId) { + super("error.msg.loan.originator.creation.not.allowed", "Cannot create originator with externalId '" + externalId + + "' during loan application. Global configuration 'enable-originator-creation-during-loan-application' is disabled.", + externalId); + } +} diff --git a/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/serialization/LoanApplicationOriginatorDataValidator.java b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/serialization/LoanApplicationOriginatorDataValidator.java new file mode 100644 index 000000000..bd12da69f --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/serialization/LoanApplicationOriginatorDataValidator.java @@ -0,0 +1,120 @@ +/** + * 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.serialization; + +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.CHANNEL_TYPE_CODE_NAME; +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.CHANNEL_TYPE_ID_PARAM; +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.EXTERNAL_ID_PARAM; +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.ORIGINATOR_TYPE_CODE_NAME; +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.ORIGINATOR_TYPE_ID_PARAM; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper; +import org.apache.fineract.infrastructure.codes.exception.CodeValueNotFoundException; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.portfolio.loanorigination.data.LoanApplicationOriginatorData; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class LoanApplicationOriginatorDataValidator { + + private static final String RESOURCE_NAME = "loan.originator"; + private static final String ID_PARAM = "id"; + private static final String NAME_PARAM = "name"; + + private final CodeValueRepositoryWrapper codeValueRepositoryWrapper; + + public LoanApplicationOriginatorData validateAndExtract(JsonObject jsonObject) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource(RESOURCE_NAME); + + final Long id = extractLong(jsonObject, ID_PARAM); + final String externalId = extractString(jsonObject, EXTERNAL_ID_PARAM); + + if (id == null && (externalId == null || externalId.isBlank())) { + baseDataValidator.reset().parameter(ID_PARAM).failWithCode("or.externalId.required", + "Either 'id' or 'externalId' must be provided for originator"); + } + + final String name = extractString(jsonObject, NAME_PARAM); + baseDataValidator.reset().parameter(NAME_PARAM).value(name).ignoreIfNull().notExceedingLengthOf(255); + + final Long typeId = extractLong(jsonObject, ORIGINATOR_TYPE_ID_PARAM); + if (typeId != null) { + validateCodeValue(typeId, ORIGINATOR_TYPE_CODE_NAME, ORIGINATOR_TYPE_ID_PARAM, baseDataValidator); + } + + final Long channelTypeId = extractLong(jsonObject, CHANNEL_TYPE_ID_PARAM); + if (channelTypeId != null) { + validateCodeValue(channelTypeId, CHANNEL_TYPE_CODE_NAME, CHANNEL_TYPE_ID_PARAM, baseDataValidator); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + + return new LoanApplicationOriginatorData(id, externalId, name, typeId, channelTypeId); + } + + private Long extractLong(JsonObject jsonObject, String paramName) { + if (jsonObject.has(paramName)) { + JsonElement element = jsonObject.get(paramName); + if (!element.isJsonNull()) { + try { + return element.getAsLong(); + } catch (NumberFormatException e) { + return null; + } + } + } + return null; + } + + private String extractString(JsonObject jsonObject, String paramName) { + if (jsonObject.has(paramName)) { + JsonElement element = jsonObject.get(paramName); + if (!element.isJsonNull()) { + return element.getAsString(); + } + } + return null; + } + + private void validateCodeValue(Long codeValueId, String codeName, String paramName, DataValidatorBuilder baseDataValidator) { + try { + this.codeValueRepositoryWrapper.findOneByCodeNameAndIdWithNotFoundDetection(codeName, codeValueId); + } catch (CodeValueNotFoundException e) { + baseDataValidator.reset().parameter(paramName).value(codeValueId).failWithCode("invalid.code.value", + "Invalid code value id " + codeValueId + " for " + codeName); + } + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } +} 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 new file mode 100644 index 000000000..bc4115a6e --- /dev/null +++ b/fineract-loan-origination/src/main/java/org/apache/fineract/portfolio/loanorigination/service/LoanOriginatorLinkingServiceImpl.java @@ -0,0 +1,158 @@ +/** + * 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 static org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants.ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION; +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.CHANNEL_TYPE_CODE_NAME; +import static org.apache.fineract.portfolio.loanorigination.api.LoanOriginatorApiConstants.ORIGINATOR_TYPE_CODE_NAME; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper; +import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationProperty; +import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepositoryWrapper; +import org.apache.fineract.infrastructure.configuration.exception.GlobalConfigurationPropertyNotFoundException; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +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.LoanOriginatorCreationNotAllowedException; +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.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 +@Service("loanOriginatorLinkingServiceImpl") +@RequiredArgsConstructor +@ConditionalOnProperty(value = "fineract.module.loan-origination.enabled", havingValue = "true") +public class LoanOriginatorLinkingServiceImpl implements LoanOriginatorLinkingService { + + private final LoanOriginatorRepository loanOriginatorRepository; + private final LoanOriginatorMappingRepository loanOriginatorMappingRepository; + private final LoanApplicationOriginatorDataValidator validator; + private final GlobalConfigurationRepositoryWrapper globalConfigurationRepository; + private final CodeValueRepositoryWrapper codeValueRepositoryWrapper; + + @Transactional + @Override + public void processOriginatorsForLoanApplication(Long loanId, JsonArray originatorsArray) { + if (originatorsArray == null || originatorsArray.isEmpty()) { + return; + } + + log.debug("Processing {} originators for loan application {}", originatorsArray.size(), loanId); + + Set attachedOriginatorIds = new HashSet<>(); + + for (JsonElement element : originatorsArray) { + if (!element.isJsonObject()) { + continue; + } + + JsonObject jsonObject = element.getAsJsonObject(); + LoanApplicationOriginatorData originatorData = validator.validateAndExtract(jsonObject); + LoanOriginator originator = resolveOrCreateOriginator(originatorData); + + if (attachedOriginatorIds.contains(originator.getId())) { + log.debug("Originator {} already attached to loan {}, skipping duplicate", originator.getId(), loanId); + continue; + } + + if (originator.getStatus() != LoanOriginatorStatus.ACTIVE) { + throw new LoanOriginatorNotActiveException(originator.getId(), originator.getStatus().getValue()); + } + + if (!loanOriginatorMappingRepository.existsByLoanIdAndOriginatorId(loanId, originator.getId())) { + LoanOriginatorMapping mapping = LoanOriginatorMapping.create(loanId, originator); + loanOriginatorMappingRepository.save(mapping); + log.debug("Attached originator {} to loan {}", originator.getId(), loanId); + } + + attachedOriginatorIds.add(originator.getId()); + } + } + + private LoanOriginator resolveOrCreateOriginator(LoanApplicationOriginatorData originatorData) { + if (originatorData.getId() != null) { + return loanOriginatorRepository.findById(originatorData.getId()) + .orElseThrow(() -> new LoanOriginatorNotFoundException(originatorData.getId())); + } + + String externalId = originatorData.getExternalId(); + Optional existingOriginator = loanOriginatorRepository.findByExternalId(new ExternalId(externalId)); + + if (existingOriginator.isPresent()) { + return existingOriginator.get(); + } + + if (!isOriginatorCreationDuringLoanApplicationEnabled()) { + throw new LoanOriginatorCreationNotAllowedException(externalId); + } + + return createNewOriginator(originatorData); + } + + private boolean isOriginatorCreationDuringLoanApplicationEnabled() { + try { + GlobalConfigurationProperty config = globalConfigurationRepository + .findOneByNameWithNotFoundDetection(ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION); + return config.isEnabled(); + } catch (GlobalConfigurationPropertyNotFoundException e) { + log.warn("Global configuration '{}' not found, defaulting to disabled", ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION); + return false; + } + } + + private LoanOriginator createNewOriginator(LoanApplicationOriginatorData data) { + log.info("Creating new originator with externalId: {} during loan application", data.getExternalId()); + + CodeValue originatorType = resolveCodeValue(data.getTypeId(), ORIGINATOR_TYPE_CODE_NAME); + CodeValue channelType = resolveCodeValue(data.getChannelTypeId(), CHANNEL_TYPE_CODE_NAME); + + LoanOriginator originator = LoanOriginator.create(new ExternalId(data.getExternalId()), data.getName(), LoanOriginatorStatus.ACTIVE, + originatorType, channelType); + + return loanOriginatorRepository.saveAndFlush(originator); + } + + private CodeValue resolveCodeValue(Long codeValueId, String codeName) { + if (codeValueId == null) { + return null; + } + return codeValueRepositoryWrapper.findOneByCodeNameAndIdWithNotFoundDetection(codeName, codeValueId); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingService.java new file mode 100644 index 000000000..4d585c264 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingService.java @@ -0,0 +1,35 @@ +/** + * 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.loanaccount.service; + +import com.google.gson.JsonArray; + +public interface LoanOriginatorLinkingService { + + /** + * Process originators provided during loan application. Creates new originators if allowed by global config, then + * attaches them to the loan. + * + * @param loanId + * the loan ID to attach originators to + * @param originatorsArray + * JSON array of originator data from loan request + */ + void processOriginatorsForLoanApplication(Long loanId, JsonArray originatorsArray); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingServiceNoOp.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingServiceNoOp.java new file mode 100644 index 000000000..5f41e3727 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanOriginatorLinkingServiceNoOp.java @@ -0,0 +1,37 @@ +/** + * 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.loanaccount.service; + +import com.google.gson.JsonArray; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.stereotype.Service; + +/** + * No-op implementation of {@link LoanOriginatorLinkingService} that is used when the loan-origination module is + * disabled. When originator data is provided during loan application, this implementation silently ignores it. + */ +@Service +@ConditionalOnMissingBean(name = "loanOriginatorLinkingServiceImpl") +public class LoanOriginatorLinkingServiceNoOp implements LoanOriginatorLinkingService { + + @Override + public void processOriginatorsForLoanApplication(Long loanId, JsonArray originatorsArray) { + // No-op when loan-origination module is not enabled + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApplicationWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApplicationWritePlatformServiceJpaRepositoryImpl.java index a27ea347f..69ac18b19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApplicationWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanApplicationWritePlatformServiceJpaRepositoryImpl.java @@ -129,6 +129,7 @@ public class LoanApplicationWritePlatformServiceJpaRepositoryImpl implements Loa private final LoanAccrualsProcessingService loanAccrualsProcessingService; private final LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator; private final LoanScheduleService loanScheduleService; + private final LoanOriginatorLinkingService loanOriginatorLinkingService; @Transactional @Override @@ -166,6 +167,13 @@ public class LoanApplicationWritePlatformServiceJpaRepositoryImpl implements Loa // Check mandatory datatable entries were created this.entityDatatableChecksWritePlatformService.runTheCheckForProduct(loan.getId(), EntityTables.LOAN.getName(), StatusEnum.CREATE.getValue(), EntityTables.LOAN.getForeignKeyColumnNameOnDatatable(), loan.productId()); + // Process originators if provided + if (command.parameterExists(LoanApiConstants.ORIGINATORS_PARAM)) { + final JsonArray originatorsArray = command.arrayOfParameterNamed(LoanApiConstants.ORIGINATORS_PARAM); + if (originatorsArray != null && !originatorsArray.isEmpty()) { + this.loanOriginatorLinkingService.processOriginatorsForLoanApplication(loan.getId(), originatorsArray); + } + } // Trigger business event businessEventNotifierService.notifyPostBusinessEvent(new LoanCreatedBusinessEvent(loan)); // Building response diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index 0afb7b400..5a0e5f013 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -150,6 +150,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanDownPaymentHandlerS import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster; import org.apache.fineract.portfolio.loanaccount.service.LoanMaximumAmountCalculator; import org.apache.fineract.portfolio.loanaccount.service.LoanOfficerService; +import org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.LoanRefundService; @@ -230,13 +231,14 @@ public class LoanAccountConfiguration { EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GLIMAccountInfoRepository glimRepository, LoanRepository loanRepository, GSIMReadPlatformService gsimReadPlatformService, LoanLifecycleStateMachine loanLifecycleStateMachine, LoanAccrualsProcessingService loanAccrualsProcessingService, - LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator, LoanScheduleService loanScheduleService) { + LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator, LoanScheduleService loanScheduleService, + LoanOriginatorLinkingService loanOriginatorLinkingService) { return new LoanApplicationWritePlatformServiceJpaRepositoryImpl(context, loanApplicationTransitionValidator, loanApplicationValidator, loanRepositoryWrapper, noteRepository, loanAssembler, calendarRepository, calendarInstanceRepository, savingsAccountRepository, accountAssociationsRepository, businessEventNotifierService, loanScheduleAssembler, loanUtilService, calendarReadPlatformService, entityDatatableChecksWritePlatformService, glimRepository, loanRepository, gsimReadPlatformService, loanLifecycleStateMachine, loanAccrualsProcessingService, - loanDownPaymentTransactionValidator, loanScheduleService); + loanDownPaymentTransactionValidator, loanScheduleService, loanOriginatorLinkingService); } @Bean diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/bulkimport/importhandler/savings/SavingsImportHandlerTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/bulkimport/importhandler/savings/SavingsImportHandlerTest.java index 86603aa18..a1f35aad2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/bulkimport/importhandler/savings/SavingsImportHandlerTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/bulkimport/importhandler/savings/SavingsImportHandlerTest.java @@ -48,6 +48,8 @@ import org.apache.fineract.integrationtests.common.organisation.StaffHelper; import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -145,8 +147,7 @@ public class SavingsImportHandlerTest { .setCellValue(savingsProductSheet.getRow(1).getCell(10).getStringCellValue()); firstSavingsRow.createCell(SavingsConstants.DECIMAL_PLACES_COL) .setCellValue(savingsProductSheet.getRow(1).getCell(11).getNumericCellValue()); - firstSavingsRow.createCell(SavingsConstants.IN_MULTIPLES_OF_COL) - .setCellValue(savingsProductSheet.getRow(1).getCell(12).getNumericCellValue()); + safeNumericValueSetter(firstSavingsRow, SavingsConstants.IN_MULTIPLES_OF_COL, savingsProductSheet, 1, 12); firstSavingsRow.createCell(SavingsConstants.NOMINAL_ANNUAL_INTEREST_RATE_COL) .setCellValue(savingsProductSheet.getRow(1).getCell(2).getNumericCellValue()); firstSavingsRow.createCell(SavingsConstants.INTEREST_COMPOUNDING_PERIOD_COL) @@ -181,7 +182,7 @@ public class SavingsImportHandlerTest { Assertions.assertNotNull(importDocumentId); // Wait for the creation of output excel - Thread.sleep(10000); + Thread.sleep(1000); // check status column of output excel String location = savingsAccountHelper.getOutputTemplateLocation(importDocumentId); @@ -196,4 +197,18 @@ public class SavingsImportHandlerTest { Assertions.assertEquals("Imported", row.getCell(SavingsConstants.STATUS_COL).getStringCellValue()); Outputworkbook.close(); } + + private void safeNumericValueSetter(Row targetRow, int targetColId, Sheet sourceSheet, int rowId, int colId) { + Row row = sourceSheet.getRow(rowId); + if (row == null) { + targetRow.createCell(targetColId).setBlank(); + } else { + Cell cell = row.getCell(colId); + if (cell == null || cell.getCellType() == CellType.BLANK) { + targetRow.createCell(targetColId).setBlank(); + } else { + targetRow.createCell(targetColId).setCellValue(cell.getNumericCellValue()); + } + } + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.java new file mode 100644 index 000000000..a2ae1f1cc --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignGlobalConfigurationHelper.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.integrationtests.client.feign.helpers; + +import static org.apache.fineract.client.feign.util.FeignCalls.ok; + +import java.util.List; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.GetGlobalConfigurationsResponse; +import org.apache.fineract.client.models.GlobalConfigurationPropertyData; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; + +public class FeignGlobalConfigurationHelper { + + private final FineractFeignClient fineractClient; + + public FeignGlobalConfigurationHelper(FineractFeignClient fineractClient) { + this.fineractClient = fineractClient; + } + + public void enableOriginatorCreationDuringLoanApplication() { + updateConfigurationByName("enable-originator-creation-during-loan-application", true); + } + + public void disableOriginatorCreationDuringLoanApplication() { + updateConfigurationByName("enable-originator-creation-during-loan-application", false); + } + + public void updateConfigurationByName(String configName, boolean enabled) { + Long configId = getConfigurationIdByName(configName); + ok(() -> fineractClient.globalConfiguration().updateConfiguration1(configId, + new PutGlobalConfigurationsRequest().enabled(enabled))); + } + + public Long getConfigurationIdByName(String configName) { + List configs = getConfigurationList(); + return configs.stream().filter(c -> configName.equals(c.getName())).findFirst().map(GlobalConfigurationPropertyData::getId) + .orElseThrow(() -> new RuntimeException("Configuration not found: " + configName)); + } + + private List getConfigurationList() { + GetGlobalConfigurationsResponse response = ok(() -> fineractClient.globalConfiguration().retrieveConfiguration(false)); + return response.getGlobalConfiguration(); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java index f00089189..5cf0c3bce 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java @@ -18,16 +18,20 @@ */ package org.apache.fineract.integrationtests.client.feign.helpers; +import static org.apache.fineract.client.feign.util.FeignCalls.fail; import static org.apache.fineract.client.feign.util.FeignCalls.ok; import java.math.BigDecimal; +import java.util.List; import java.util.Map; import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsResponse; import org.apache.fineract.client.models.PostLoansLoanIdRequest; import org.apache.fineract.client.models.PostLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansOriginatorData; import org.apache.fineract.client.models.PostLoansRequest; import org.apache.fineract.client.models.PostLoansResponse; @@ -170,4 +174,43 @@ public class FeignLoanHelper { .format(org.apache.fineract.integrationtests.common.Utils.getLocalDateOfTenant()); return createSubmittedLoan(clientId, productId, todayDate, 10000.0, 12); } + + public Long createSubmittedLoanWithOriginators(Long clientId, List originators) { + PostLoansRequest request = buildSubmittedLoanRequest(clientId); + request.setOriginators(originators); + PostLoansResponse response = ok(() -> fineractClient.loans().calculateLoanScheduleOrSubmitLoanApplication(request, (String) null)); + return response.getLoanId(); + } + + public CallFailedRuntimeException createSubmittedLoanWithOriginatorsExpectingError(Long clientId, + List originators) { + PostLoansRequest request = buildSubmittedLoanRequest(clientId); + request.setOriginators(originators); + return fail(() -> fineractClient.loans().calculateLoanScheduleOrSubmitLoanApplication(request, (String) null)); + } + + private PostLoansRequest buildSubmittedLoanRequest(Long clientId) { + Long productId = createSimpleLoanProduct(); + String todayDate = org.apache.fineract.integrationtests.common.Utils.dateFormatter + .format(org.apache.fineract.integrationtests.common.Utils.getLocalDateOfTenant()); + return new PostLoansRequest()// + .clientId(clientId)// + .productId(productId)// + .loanType("individual")// + .submittedOnDate(todayDate)// + .expectedDisbursementDate(todayDate)// + .principal(BigDecimal.valueOf(10000.0))// + .loanTermFrequency(12)// + .loanTermFrequencyType(2)// + .numberOfRepayments(12)// + .repaymentEvery(1)// + .repaymentFrequencyType(2)// + .interestRatePerPeriod(BigDecimal.ZERO)// + .amortizationType(1)// + .interestType(0)// + .interestCalculationPeriodType(1)// + .transactionProcessingStrategyCode("mifos-standard-strategy")// + .locale("en")// + .dateFormat("dd MMMM yyyy"); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java new file mode 100644 index 000000000..8fb841e3b --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanOriginatorDuringApplicationTest.java @@ -0,0 +1,218 @@ +/** + * 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.tests; + +import java.util.List; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.PostLoansOriginatorData; +import org.apache.fineract.integrationtests.client.FeignIntegrationTest; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignGlobalConfigurationHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignLoanHelper; +import org.apache.fineract.integrationtests.client.feign.helpers.FeignLoanOriginatorHelper; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +@Order(2) +public class FeignLoanOriginatorDuringApplicationTest extends FeignIntegrationTest { + + private static FeignLoanOriginatorHelper originatorHelper; + private static FeignClientHelper clientHelper; + private static FeignLoanHelper loanHelper; + private static FeignGlobalConfigurationHelper configHelper; + + @BeforeAll + public static void setup() { + FineractFeignClient fineractClient = FineractFeignClientHelper.getFineractFeignClient(); + originatorHelper = new FeignLoanOriginatorHelper(fineractClient); + clientHelper = new FeignClientHelper(fineractClient); + loanHelper = new FeignLoanHelper(fineractClient); + configHelper = new FeignGlobalConfigurationHelper(fineractClient); + } + + @Test + public void testCreateLoanWithExistingOriginatorById() { + final String originatorExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId); + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().id(originatorId)); + final Long loanId = loanHelper.createSubmittedLoanWithOriginators(clientId, originators); + + assertThat(loanId).isNotNull(); + final var loanDetails = loanHelper.getLoanDetailsWithAssociations(loanId, "originators"); + assertThat(loanDetails.getOriginators()).hasSize(1); + assertThat(loanDetails.getOriginators().get(0).getId()).isEqualTo(originatorId); + + originatorHelper.detachOriginatorFromLoan(loanId, originatorId); + originatorHelper.deleteOriginator(originatorId); + } + + @Test + public void testCreateLoanWithExistingOriginatorByExternalId() { + final String originatorExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(originatorExternalId); + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().externalId(originatorExternalId)); + final Long loanId = loanHelper.createSubmittedLoanWithOriginators(clientId, originators); + + assertThat(loanId).isNotNull(); + final var loanDetails = loanHelper.getLoanDetailsWithAssociations(loanId, "originators"); + assertThat(loanDetails.getOriginators()).hasSize(1); + assertThat(loanDetails.getOriginators().get(0).getExternalId()).isEqualTo(originatorExternalId); + + originatorHelper.detachOriginatorFromLoan(loanId, originatorId); + originatorHelper.deleteOriginator(originatorId); + } + + @Test + public void testCreateLoanWithNewOriginatorWhenConfigEnabled() { + configHelper.enableOriginatorCreationDuringLoanApplication(); + + try { + final Long clientId = clientHelper.createClient(); + final String newOriginatorExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + + final List originators = List + .of(new PostLoansOriginatorData().externalId(newOriginatorExternalId).name("New Merchant Created During Loan")); + final Long loanId = loanHelper.createSubmittedLoanWithOriginators(clientId, originators); + + assertThat(loanId).isNotNull(); + + final var createdOriginator = originatorHelper.getOriginatorByExternalId(newOriginatorExternalId); + assertThat(createdOriginator).isNotNull(); + assertThat(createdOriginator.getName()).isEqualTo("New Merchant Created During Loan"); + assertThat(createdOriginator.getStatus()).isEqualTo("ACTIVE"); + + final var loanDetails = loanHelper.getLoanDetailsWithAssociations(loanId, "originators"); + assertThat(loanDetails.getOriginators()).hasSize(1); + assertThat(loanDetails.getOriginators().get(0).getExternalId()).isEqualTo(newOriginatorExternalId); + + originatorHelper.detachOriginatorFromLoan(loanId, createdOriginator.getId()); + originatorHelper.deleteOriginator(createdOriginator.getId()); + } finally { + configHelper.disableOriginatorCreationDuringLoanApplication(); + } + } + + @Test + public void testCreateLoanWithNewOriginatorFailsWhenConfigDisabled() { + configHelper.disableOriginatorCreationDuringLoanApplication(); + + final Long clientId = clientHelper.createClient(); + final String nonExistingExternalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + + final List originators = List.of(new PostLoansOriginatorData().externalId(nonExistingExternalId)); + + final CallFailedRuntimeException exception = loanHelper.createSubmittedLoanWithOriginatorsExpectingError(clientId, originators); + + assertThat(exception.getStatus()).isIn(403, 404); + } + + @Test + public void testCreateLoanWithMultipleOriginators() { + final String externalId1 = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final String externalId2 = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId1 = originatorHelper.createOriginator(externalId1); + final Long originatorId2 = originatorHelper.createOriginator(externalId2); + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().id(originatorId1), + new PostLoansOriginatorData().externalId(externalId2)); + final Long loanId = loanHelper.createSubmittedLoanWithOriginators(clientId, originators); + + final var loanDetails = loanHelper.getLoanDetailsWithAssociations(loanId, "originators"); + assertThat(loanDetails.getOriginators()).hasSize(2); + + originatorHelper.detachOriginatorFromLoan(loanId, originatorId1); + originatorHelper.detachOriginatorFromLoan(loanId, originatorId2); + originatorHelper.deleteOriginator(originatorId1); + originatorHelper.deleteOriginator(originatorId2); + } + + @Test + public void testCreateLoanWithInvalidOriginatorDataReturns400() { + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().name("Invalid - no id or externalId")); + + final CallFailedRuntimeException exception = loanHelper.createSubmittedLoanWithOriginatorsExpectingError(clientId, originators); + + assertThat(exception.getStatus()).isEqualTo(400); + } + + @Test + public void testCreateLoanWithInactiveOriginatorReturns403() { + final String externalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(externalId, "Inactive Originator", "INACTIVE"); + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().id(originatorId)); + + final CallFailedRuntimeException exception = loanHelper.createSubmittedLoanWithOriginatorsExpectingError(clientId, originators); + + assertThat(exception.getStatus()).isEqualTo(403); + + originatorHelper.deleteOriginator(originatorId); + } + + @Test + public void testCreateLoanWithNonExistingOriginatorIdReturns404() { + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().id(999999L)); + + final CallFailedRuntimeException exception = loanHelper.createSubmittedLoanWithOriginatorsExpectingError(clientId, originators); + + assertThat(exception.getStatus()).isEqualTo(404); + } + + @Test + public void testCreateLoanWithoutOriginatorsStillWorks() { + final Long clientId = clientHelper.createClient(); + + final Long loanId = loanHelper.createSubmittedLoan(clientId); + + assertThat(loanId).isNotNull(); + + final var loanDetails = loanHelper.getLoanDetailsWithAssociations(loanId, "originators"); + assertThat(loanDetails.getOriginators()).isEmpty(); + } + + @Test + public void testCreateLoanWithDuplicateOriginatorInListAttachesOnce() { + final String externalId = FeignLoanOriginatorHelper.generateUniqueExternalId(); + final Long originatorId = originatorHelper.createOriginator(externalId); + final Long clientId = clientHelper.createClient(); + + final List originators = List.of(new PostLoansOriginatorData().id(originatorId), + new PostLoansOriginatorData().externalId(externalId)); + final Long loanId = loanHelper.createSubmittedLoanWithOriginators(clientId, originators); + + final var loanDetails = loanHelper.getLoanDetailsWithAssociations(loanId, "originators"); + assertThat(loanDetails.getOriginators()).hasSize(1); + + originatorHelper.detachOriginatorFromLoan(loanId, originatorId); + originatorHelper.deleteOriginator(originatorId); + } +}