FINERACT-2418: add originator during the loan application

This commit is contained in:
Attila Budai
2026-02-09 14:40:34 +01:00
committed by Adam Saghy
parent f5951f1a7b
commit 99861ce93a
12 changed files with 767 additions and 5 deletions
@@ -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;
}
@@ -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);
}
}
@@ -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<ApiParameterError> 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<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
}
@@ -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<Long> 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<LoanOriginator> 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);
}
}