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 fed93fe0e27..0ea5778ea12 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
@@ -3871,6 +3871,15 @@ public CommandWrapperBuilder updateExternalAssetOwnerLoanProductAttribute(final
return this;
}
+ public CommandWrapperBuilder deleteExternalAssetOwnerLoanProductAttribute(final Long loanProductId, final Long attributeId) {
+ this.actionName = ACTION_DELETE;
+ this.entityName = ENTITY_EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE;
+ this.productId = loanProductId;
+ this.entityId = attributeId;
+ this.href = "/external-asset-owners/loan-product/" + loanProductId + "/attributes/" + attributeId;
+ return this;
+ }
+
public CommandWrapperBuilder intermediarySaleLoanToExternalAssetOwner(final Long loanId) {
this.actionName = ACTION_INTERMEDIARYSALE;
this.entityName = ENTITY_LOAN;
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResource.java b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResource.java
index 2e46287db9d..e03f4f60feb 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResource.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResource.java
@@ -25,6 +25,7 @@
import io.swagger.v3.oas.annotations.parameters.RequestBody;
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.PUT;
@@ -127,4 +128,22 @@ public CommandProcessingResult updateLoanProductAttribute(
return commandsSourceWritePlatformService.logCommandSource(request);
}
+ @DELETE
+ @Path("/{loanProductId}/attributes/{id}")
+ @Produces({ MediaType.APPLICATION_JSON })
+ @Operation(tags = {
+ "External Asset Owner Loan Product Attributes" }, summary = "Delete a Loan Product Attribute", operationId = "deleteExternalAssetOwnerLoanProductAttribute", description = "Deletes a loan product attribute with a given loan product id and attribute id, so that the loan product falls back to the default behaviour of the attribute", parameters = {
+ @Parameter(name = "loanProductId", description = "loanProductId"),
+ @Parameter(name = "attributeId", description = "attributeId") })
+ @AlternativeOperationId("deleteLoanProductAttribute")
+ public CommandProcessingResult deleteLoanProductAttribute(
+ @PathParam("loanProductId") @Parameter(description = "loanProductId") final Long loanProductId,
+ @PathParam("id") @Parameter(description = "attributeId") final Long attributeId) {
+ platformUserRightsContext.isAuthenticated();
+ final CommandWrapperBuilder builder = new CommandWrapperBuilder();
+ CommandWrapper request = builder.deleteExternalAssetOwnerLoanProductAttribute(loanProductId, attributeId).build();
+
+ return commandsSourceWritePlatformService.logCommandSource(request);
+ }
+
}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResourceSwagger.java b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResourceSwagger.java
index a0db5f0b7f2..ab49c5e013c 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResourceSwagger.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/api/ExternalAssetOwnerLoanProductAttributesApiResourceSwagger.java
@@ -30,10 +30,10 @@ public static final class PostExternalAssetOwnerLoanProductAttributeRequest {
private PostExternalAssetOwnerLoanProductAttributeRequest() {}
- @Schema(example = "SETTLEMENT_MODEL")
+ @Schema(example = "SETTLEMENT_MODEL", description = "Attribute key, one of the keys returned by the loan product attributes template endpoint, e.g. SETTLEMENT_MODEL or EXCLUDED_TRANSACTION_TYPES.")
public String attributeKey;
- @Schema(example = "DELAYED_SETTLEMENT")
+ @Schema(example = "DELAYED_SETTLEMENT", description = "Attribute value. For multi value attributes such as EXCLUDED_TRANSACTION_TYPES this is a comma separated list, e.g. BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT.")
public String attributeValue;
}
@@ -42,10 +42,10 @@ public static final class PutExternalAssetOwnerLoanProductAttributeRequest {
private PutExternalAssetOwnerLoanProductAttributeRequest() {}
- @Schema(example = "SETTLEMENT_MODEL")
+ @Schema(example = "SETTLEMENT_MODEL", description = "Attribute key, one of the keys returned by the loan product attributes template endpoint, e.g. SETTLEMENT_MODEL or EXCLUDED_TRANSACTION_TYPES.")
public String attributeKey;
- @Schema(example = "DELAYED_SETTLEMENT_DISABLED")
+ @Schema(example = "DELAYED_SETTLEMENT_DISABLED", description = "Attribute value. For multi value attributes such as EXCLUDED_TRANSACTION_TYPES this is a comma separated list, e.g. BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT.")
public String attributeValue;
}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.java
new file mode 100644
index 00000000000..737c6cc8600
--- /dev/null
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.java
@@ -0,0 +1,91 @@
+/**
+ * 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.investor.data.attribute;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
+
+/**
+ * Multi value attribute holding the loan transaction types that must be kept out of external asset owner accounting and
+ * reporting. The value is a comma separated list of {@link LoanTransactionType} names.
+ *
+ * This cannot be modelled as an enum the way {@link SettlementModelExternalAssetOwnerLoanProductAttribute} is, because
+ * the allowed values are the constants of another enum and any combination of them is a valid attribute value.
+ */
+public class ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute implements ExternalAssetOwnerLoanProductAttribute {
+
+ public static final String ATTRIBUTE_KEY = "EXCLUDED_TRANSACTION_TYPES";
+
+ private static final String SEPARATOR = ",";
+
+ @Override
+ public String getAttributeKey() {
+ return ATTRIBUTE_KEY;
+ }
+
+ @Override
+ public String getAttributeValue() {
+ return null;
+ }
+
+ @Override
+ public List getAttributeValues() {
+ return Arrays.stream(LoanTransactionType.values()).filter(type -> !LoanTransactionType.INVALID.equals(type)).map(Enum::name)
+ .toList();
+ }
+
+ @Override
+ public boolean validate(String attributeValue) {
+ if (attributeValue == null || attributeValue.isBlank()) {
+ return false;
+ }
+ List allowedValues = getAttributeValues();
+ Set seenValues = new LinkedHashSet<>();
+ for (String token : attributeValue.split(SEPARATOR, -1)) {
+ String normalizedToken = normalizeToken(token);
+ if (!allowedValues.contains(normalizedToken) || !seenValues.add(normalizedToken)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public boolean isMultiValue() {
+ return true;
+ }
+
+ @Override
+ public String normalize(String attributeValue) {
+ List normalizedTokens = new ArrayList<>();
+ for (String token : attributeValue.split(SEPARATOR, -1)) {
+ normalizedTokens.add(normalizeToken(token));
+ }
+ return String.join(SEPARATOR, normalizedTokens);
+ }
+
+ private String normalizeToken(String token) {
+ return token.trim().toUpperCase(Locale.ROOT);
+ }
+}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExternalAssetOwnerLoanProductAttribute.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExternalAssetOwnerLoanProductAttribute.java
index 2782ebfbfd0..4edd9d3a26f 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExternalAssetOwnerLoanProductAttribute.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/ExternalAssetOwnerLoanProductAttribute.java
@@ -31,4 +31,13 @@ public interface ExternalAssetOwnerLoanProductAttribute {
boolean validate(String attributeValue);
boolean isMultiValue();
+
+ /**
+ * Converts an already validated attribute value into its canonical, storable form. Implementations that accept
+ * loosely formatted input (different casing, padding around separators) return the normalised value here, so that
+ * the persisted value is always canonical.
+ */
+ default String normalize(String attributeValue) {
+ return attributeValue;
+ }
}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerLoanProductAttributes.java b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerLoanProductAttributes.java
index 80e538182eb..59a09460441 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerLoanProductAttributes.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/domain/ExternalAssetOwnerLoanProductAttributes.java
@@ -39,7 +39,7 @@ public class ExternalAssetOwnerLoanProductAttributes extends AbstractAuditableWi
@Column(name = "attribute_key", nullable = false)
private String attributeKey;
- @Column(name = "attribute_value", nullable = false)
+ @Column(name = "attribute_value", nullable = false, length = 2000)
private String attributeValue;
}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/DeleteExternalAssetOwnerLoanProductAttributeHandler.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/DeleteExternalAssetOwnerLoanProductAttributeHandler.java
new file mode 100644
index 00000000000..2924c4ff406
--- /dev/null
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/DeleteExternalAssetOwnerLoanProductAttributeHandler.java
@@ -0,0 +1,39 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.investor.service;
+
+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.springframework.stereotype.Service;
+
+@RequiredArgsConstructor
+@Service
+@CommandType(entity = "EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE", action = "DELETE")
+public class DeleteExternalAssetOwnerLoanProductAttributeHandler implements NewCommandSourceHandler {
+
+ private final ExternalAssetOwnerLoanProductAttributesWriteService externalAssetOwnerLoanProductAttributesWriteService;
+
+ @Override
+ public CommandProcessingResult processCommand(JsonCommand command) {
+ return externalAssetOwnerLoanProductAttributesWriteService.deleteExternalAssetOwnerLoanProductAttribute(command);
+ }
+}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributeProvider.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributeProvider.java
new file mode 100644
index 00000000000..fc902d1e15d
--- /dev/null
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributeProvider.java
@@ -0,0 +1,73 @@
+/**
+ * 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.investor.service;
+
+import static org.reflections.scanners.Scanners.SubTypes;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.apache.fineract.investor.data.attribute.ExternalAssetOwnerLoanProductAttribute;
+import org.reflections.Reflections;
+import org.springframework.stereotype.Component;
+
+/**
+ * Discovers every {@link ExternalAssetOwnerLoanProductAttribute} implementation on the classpath and exposes them as
+ * ready to use instances. Enum implementations contribute one instance per constant, non enum implementations are
+ * instantiated through their no-arg constructor.
+ */
+@Component
+public final class ExternalAssetOwnerLoanProductAttributeProvider {
+
+ private static final String INVESTOR_PATH = "org.apache.fineract.investor";
+
+ private final List attributes;
+
+ public ExternalAssetOwnerLoanProductAttributeProvider() {
+ Set> implementingClasses = new Reflections(INVESTOR_PATH)
+ .get(SubTypes.of(ExternalAssetOwnerLoanProductAttribute.class).asClass());
+ List resolved = new ArrayList<>();
+ for (Class> implementingClass : implementingClasses) {
+ if (implementingClass.isEnum()) {
+ Arrays.stream(implementingClass.getEnumConstants()).map(ExternalAssetOwnerLoanProductAttribute.class::cast)
+ .forEach(resolved::add);
+ } else {
+ resolved.add(createAttribute(implementingClass));
+ }
+ }
+ this.attributes = List.copyOf(resolved);
+ }
+
+ /**
+ * @return every discovered attribute instance; enum implementations are expanded to their constants.
+ */
+ public List retrieveAll() {
+ return attributes;
+ }
+
+ private static ExternalAssetOwnerLoanProductAttribute createAttribute(final Class> implementingClass) {
+ try {
+ return (ExternalAssetOwnerLoanProductAttribute) implementingClass.getDeclaredConstructor().newInstance();
+ } catch (ReflectiveOperationException | ClassCastException exception) {
+ throw new IllegalStateException("Unable to create external asset owner loan product attribute: " + implementingClass.getName(),
+ exception);
+ }
+ }
+}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceImpl.java
index 19b3c80f522..3b05ef86525 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceImpl.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceImpl.java
@@ -18,13 +18,9 @@
*/
package org.apache.fineract.investor.service;
-import static org.reflections.scanners.Scanners.SubTypes;
-
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
-import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.Page;
@@ -35,7 +31,6 @@
import org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributesRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository;
import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundException;
-import org.reflections.Reflections;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@@ -48,26 +43,16 @@
@Transactional(readOnly = true)
public class ExternalAssetOwnerLoanProductAttributesReadServiceImpl implements ExternalAssetOwnerLoanProductAttributesReadService {
- private static final String INVESTOR_PATH = "org.apache.fineract.investor";
-
private final ExternalAssetOwnerLoanProductAttributesRepository externalAssetOwnerLoanProductAttributesRepository;
private final LoanProductRepository loanProductRepository;
private final ExternalAssetOwnerLoanProductAttributesMapper mapper;
- private final Set> implementingClasses = new Reflections(INVESTOR_PATH)
- .get(SubTypes.of(ExternalAssetOwnerLoanProductAttribute.class).asClass());
+ private final ExternalAssetOwnerLoanProductAttributeProvider attributeProvider;
@Override
public List retrieveExternalAssetOwnerLoanProductAttributesTemplate() {
List result = new ArrayList<>();
- for (Class> implementingClass : implementingClasses) {
- if (implementingClass.isEnum()) {
- Arrays.stream(implementingClass.getEnumConstants()).map(ExternalAssetOwnerLoanProductAttribute.class::cast)
- .forEach(attribute -> addAttributeValues(result, attribute));
- } else {
- addAttributeValues(result, createAttribute(implementingClass));
- }
- }
+ attributeProvider.retrieveAll().forEach(attribute -> addAttributeValues(result, attribute));
return result;
}
@@ -101,15 +86,6 @@ private void addAttributeValues(List implementingClass) {
- try {
- return (ExternalAssetOwnerLoanProductAttribute) implementingClass.getDeclaredConstructor().newInstance();
- } catch (ReflectiveOperationException | ClassCastException exception) {
- throw new IllegalStateException("Unable to create external asset owner loan product attribute: " + implementingClass.getName(),
- exception);
- }
- }
-
private void validateLoanProduct(final Long loanProductId) {
if (loanProductId == null) {
throw new IllegalArgumentException("At least one of the following parameters must be provided: loanProductId");
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteService.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteService.java
index 13aa8d746ed..b7ff452160d 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteService.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteService.java
@@ -27,4 +27,6 @@ public interface ExternalAssetOwnerLoanProductAttributesWriteService {
CommandProcessingResult updateExternalAssetOwnerLoanProductAttribute(JsonCommand command, String attributeKey, String attributeValue);
+ CommandProcessingResult deleteExternalAssetOwnerLoanProductAttribute(JsonCommand command);
+
}
diff --git a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImpl.java b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImpl.java
index 3dee2ea0a5a..b5a2c24c9c5 100644
--- a/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImpl.java
+++ b/fineract-investor/src/main/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImpl.java
@@ -18,8 +18,6 @@
*/
package org.apache.fineract.investor.service;
-import static org.reflections.scanners.Scanners.SubTypes;
-
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
@@ -48,7 +46,6 @@
import org.apache.fineract.investor.exception.ExternalAssetOwnerLoanProductAttributesException;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository;
import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundException;
-import org.reflections.Reflections;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -58,13 +55,12 @@
@Transactional
public class ExternalAssetOwnerLoanProductAttributesWriteServiceImpl implements ExternalAssetOwnerLoanProductAttributesWriteService {
- private static final String INVESTOR_PATH = "org.apache.fineract.investor";
+ private static final int ATTRIBUTE_VALUE_MAX_LENGTH = 2000;
private final FromJsonHelper fromApiJsonHelper;
private final ExternalAssetOwnerLoanProductAttributesRepository externalAssetOwnerLoanProductAttributesRepository;
private final LoanProductRepository loanProductRepository;
- private final Set> implementingClasses = new Reflections(INVESTOR_PATH)
- .get(SubTypes.of(ExternalAssetOwnerLoanProductAttribute.class).asClass());
+ private final ExternalAssetOwnerLoanProductAttributeProvider attributeProvider;
@Override
public CommandProcessingResult createExternalAssetOwnerLoanProductAttribute(JsonCommand command) {
@@ -75,10 +71,10 @@ public CommandProcessingResult createExternalAssetOwnerLoanProductAttribute(Json
.extractStringNamed(ExternalAssetOwnerLoanProductAttributeRequestParameters.ATTRIBUTE_VALUE, json);
Long loanProductId = command.getProductId();
validateLoanProductAttributeRequest(command.json(), attributeKey, attributeValue);
- validateExternalAssetOwnerLoanProductAttribute(attributeKey, attributeValue);
+ String normalizedAttributeValue = validateAndNormalizeExternalAssetOwnerLoanProductAttribute(attributeKey, attributeValue);
validateLoanProductExistsAndAttributeDoesNotExist(loanProductId, attributeKey);
ExternalAssetOwnerLoanProductAttributes newAttribute = createExternalAssetOwnerLoanProductAttribute(loanProductId, attributeKey,
- attributeValue);
+ normalizedAttributeValue);
externalAssetOwnerLoanProductAttributesRepository.saveAndFlush(newAttribute);
return buildResponseData(newAttribute);
}
@@ -90,17 +86,29 @@ public CommandProcessingResult updateExternalAssetOwnerLoanProductAttribute(Json
Long loanProductId = command.getProductId();
Long attributeId = command.entityId();
validateLoanProductAttributeRequest(command.json(), attributeKey, attributeValue);
- validateExternalAssetOwnerLoanProductAttribute(attributeKey, attributeValue);
+ String normalizedAttributeValue = validateAndNormalizeExternalAssetOwnerLoanProductAttribute(attributeKey, attributeValue);
validateLoanProductExists(loanProductId);
ExternalAssetOwnerLoanProductAttributes attributeToUpdate = getLoanProductAttribute(attributeId);
validateLoanProductAttributeKeysMatch(attributeKey, attributeToUpdate.getAttributeKey());
- if (!attributeToUpdate.getAttributeValue().equals(attributeValue)) {
- attributeToUpdate.setAttributeValue(attributeValue);
+ if (!attributeToUpdate.getAttributeValue().equals(normalizedAttributeValue)) {
+ attributeToUpdate.setAttributeValue(normalizedAttributeValue);
externalAssetOwnerLoanProductAttributesRepository.saveAndFlush(attributeToUpdate);
}
return buildResponseData(attributeToUpdate);
}
+ @Override
+ @CacheEvict(cacheNames = "externalAssetOwnerLoanProductAttributes", allEntries = true)
+ public CommandProcessingResult deleteExternalAssetOwnerLoanProductAttribute(JsonCommand command) {
+ Long loanProductId = command.getProductId();
+ Long attributeId = command.entityId();
+ validateLoanProductExists(loanProductId);
+ ExternalAssetOwnerLoanProductAttributes attributeToDelete = getLoanProductAttribute(attributeId);
+ validateLoanProductAttributeBelongsToLoanProduct(loanProductId, attributeToDelete);
+ externalAssetOwnerLoanProductAttributesRepository.delete(attributeToDelete);
+ return buildResponseData(attributeToDelete);
+ }
+
private void validateLoanProductAttributeRequest(String apiRequestBodyAsJson, String attributeKey, String attributeValue) {
final Set requestParameters = new HashSet<>(
Arrays.asList(ExternalAssetOwnerLoanProductAttributeRequestParameters.ATTRIBUTE_KEY,
@@ -115,7 +123,7 @@ private void validateLoanProductAttributeRequest(String apiRequestBodyAsJson, St
.notBlank().notExceedingLengthOf(255);
baseDataValidator.reset().parameter(ExternalAssetOwnerLoanProductAttributeRequestParameters.ATTRIBUTE_VALUE).value(attributeValue)
- .notBlank().notExceedingLengthOf(255);
+ .notBlank().notExceedingLengthOf(ATTRIBUTE_VALUE_MAX_LENGTH);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
@@ -141,6 +149,13 @@ private void validateLoanProductAttributeDoesNotExist(Long loanProductId, String
}
}
+ private void validateLoanProductAttributeBelongsToLoanProduct(Long loanProductId, ExternalAssetOwnerLoanProductAttributes attribute) {
+ if (!attribute.getLoanProductId().equals(loanProductId)) {
+ throw new ExternalAssetOwnerLoanProductAttributesException(
+ "The requested attribute does not belong to the loanProductId: " + loanProductId + ".");
+ }
+ }
+
private void validateLoanProductAttributeKeysMatch(String attributeKeyFromRequest, String attributeKeyFromDB) {
if (!attributeKeyFromRequest.equals(attributeKeyFromDB)) {
throw new ExternalAssetOwnerLoanProductAttributesException(
@@ -148,15 +163,10 @@ private void validateLoanProductAttributeKeysMatch(String attributeKeyFromReques
}
}
- private void validateExternalAssetOwnerLoanProductAttribute(String attributeKey, String attributeValue) {
- for (Class> implementingClass : implementingClasses) {
- if (implementingClass.isEnum()) {
- for (Object obj : implementingClass.getEnumConstants()) {
- ExternalAssetOwnerLoanProductAttribute objEnum = (ExternalAssetOwnerLoanProductAttribute) obj;
- if (objEnum.getAttributeKey().equals(attributeKey) && objEnum.validate(attributeValue)) {
- return;
- }
- }
+ private String validateAndNormalizeExternalAssetOwnerLoanProductAttribute(String attributeKey, String attributeValue) {
+ for (ExternalAssetOwnerLoanProductAttribute attribute : attributeProvider.retrieveAll()) {
+ if (attribute.getAttributeKey().equals(attributeKey) && attribute.validate(attributeValue)) {
+ return attribute.normalize(attributeValue);
}
}
throw new ExternalAssetOwnerLoanProductAttributeInvalidSettlementAttributeException(
diff --git a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml
index 10dc6bbf3f1..2546467d009 100644
--- a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml
+++ b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/module-changelog-master.xml
@@ -44,4 +44,6 @@
+
+
diff --git a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0023_widen_external_asset_owner_loan_product_attribute_value.xml b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0023_widen_external_asset_owner_loan_product_attribute_value.xml
new file mode 100644
index 00000000000..47061a02a56
--- /dev/null
+++ b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0023_widen_external_asset_owner_loan_product_attribute_value.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0024_add_external_asset_owner_loan_product_attribute_delete_permission.xml b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0024_add_external_asset_owner_loan_product_attribute_delete_permission.xml
new file mode 100644
index 00000000000..34931d15d1c
--- /dev/null
+++ b/fineract-investor/src/main/resources/db/changelog/tenant/module/investor/parts/0024_add_external_asset_owner_loan_product_attribute_delete_permission.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/data/attribute/ExcludedTransactionTypesExternalAssetOwnerLoanProductAttributeTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/data/attribute/ExcludedTransactionTypesExternalAssetOwnerLoanProductAttributeTest.java
new file mode 100644
index 00000000000..93143583e52
--- /dev/null
+++ b/fineract-investor/src/test/java/org/apache/fineract/investor/data/attribute/ExcludedTransactionTypesExternalAssetOwnerLoanProductAttributeTest.java
@@ -0,0 +1,85 @@
+/**
+ * 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.investor.data.attribute;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+public class ExcludedTransactionTypesExternalAssetOwnerLoanProductAttributeTest {
+
+ private final ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute underTest = new ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute();
+
+ @Test
+ public void testAttributeKeyIsExcludedTransactionTypes() {
+ assertEquals("EXCLUDED_TRANSACTION_TYPES", underTest.getAttributeKey());
+ }
+
+ @Test
+ public void testAttributeIsMultiValue() {
+ assertTrue(underTest.isMultiValue());
+ }
+
+ @Test
+ public void testAttributeValuesContainEveryLoanTransactionTypeExceptInvalid() {
+ List expected = Arrays.stream(LoanTransactionType.values()).filter(type -> !LoanTransactionType.INVALID.equals(type))
+ .map(Enum::name).toList();
+
+ List actual = underTest.getAttributeValues();
+
+ assertEquals(expected, actual);
+ assertFalse(actual.contains(LoanTransactionType.INVALID.name()));
+ }
+
+ @ParameterizedTest
+ @MethodSource("validAttributeValues")
+ public void testValidValuesAreAcceptedAndNormalized(String testName, String attributeValue, String expectedNormalizedValue) {
+ assertTrue(underTest.validate(attributeValue), testName);
+ assertEquals(expectedNormalizedValue, underTest.normalize(attributeValue), testName);
+ }
+
+ private static Stream validAttributeValues() {
+ return Stream.of(Arguments.of("single value", "BUY_DOWN_FEE", "BUY_DOWN_FEE"), //
+ Arguments.of("the buy down fee family", //
+ "BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT,BUY_DOWN_FEE_AMORTIZATION,BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT", //
+ "BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT,BUY_DOWN_FEE_AMORTIZATION,BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT"), //
+ Arguments.of("mixed case and padding", "buy_down_fee, BUY_DOWN_FEE_ADJUSTMENT", "BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT"), //
+ Arguments.of("surrounding whitespace", " BUY_DOWN_FEE ", "BUY_DOWN_FEE"), //
+ Arguments.of("order is preserved", "REPAYMENT,ACCRUAL", "REPAYMENT,ACCRUAL"));
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = { " ", ",", "BUY_DOWN_FEE,", ",BUY_DOWN_FEE", "BUY_DOWN_FEE,,ACCRUAL", "BUY_DOWN_FEE,BUY_DOWN_FEE",
+ "buy_down_fee,BUY_DOWN_FEE", "BUY_DOWN_FEE,NOT_A_TYPE", "NOT_A_TYPE", "INVALID", "BUY_DOWN_FEE;ACCRUAL" })
+ public void testInvalidValuesAreRejected(String attributeValue) {
+ assertFalse(underTest.validate(attributeValue));
+ }
+}
diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceTest.java
index 275df80c025..cc013264451 100644
--- a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceTest.java
+++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesReadServiceTest.java
@@ -22,19 +22,23 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
+import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.investor.data.ExternalTransferLoanProductAttributesData;
import org.apache.fineract.investor.data.ExternalTransferLoanProductAttributesTemplateData;
+import org.apache.fineract.investor.data.attribute.ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute;
import org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributes;
import org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributesRepository;
+import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository;
import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundException;
import org.junit.jupiter.api.BeforeEach;
@@ -73,7 +77,7 @@ public class ExternalAssetOwnerLoanProductAttributesReadServiceTest {
@BeforeEach
public void setUp() {
underTest = new ExternalAssetOwnerLoanProductAttributesReadServiceImpl(externalAssetOwnerLoanProductAttributesRepository,
- loanProductRepository, mapper);
+ loanProductRepository, mapper, new ExternalAssetOwnerLoanProductAttributeProvider());
}
@ParameterizedTest
@@ -112,9 +116,17 @@ public void testRetrieveExternalAssetOwnerLoanProductAttributesTemplate() {
// then
ExternalTransferLoanProductAttributesTemplateData settlementModel = result.stream()
.filter(attribute -> "SETTLEMENT_MODEL".equals(attribute.getAttributeKey())).findFirst().orElseThrow();
- assertEquals(1, result.size());
+ assertEquals(2, result.size());
assertEquals(List.of("DEFAULT_SETTLEMENT", "DELAYED_SETTLEMENT"), settlementModel.getAttributeValues());
assertFalse(settlementModel.isMultiValue());
+
+ ExternalTransferLoanProductAttributesTemplateData excludedTransactionTypes = result.stream()
+ .filter(attribute -> ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY
+ .equals(attribute.getAttributeKey()))
+ .findFirst().orElseThrow();
+ assertTrue(excludedTransactionTypes.isMultiValue());
+ assertEquals(Arrays.stream(LoanTransactionType.values()).filter(type -> !LoanTransactionType.INVALID.equals(type)).map(Enum::name)
+ .toList(), excludedTransactionTypes.getAttributeValues());
}
@Test
diff --git a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImplTest.java b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImplTest.java
index 6f420bdf3f0..d54c1edc30b 100644
--- a/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImplTest.java
+++ b/fineract-investor/src/test/java/org/apache/fineract/investor/service/ExternalAssetOwnerLoanProductAttributesWriteServiceImplTest.java
@@ -32,9 +32,11 @@
import java.util.stream.Stream;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
+import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.investor.data.ExternalAssetOwnerLoanProductAttributeRequestParameters;
+import org.apache.fineract.investor.data.attribute.ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute;
import org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributes;
import org.apache.fineract.investor.domain.ExternalAssetOwnerLoanProductAttributesRepository;
import org.apache.fineract.investor.exception.ExternalAssetOwnerLoanProductAttributeAlreadyExistsException;
@@ -49,8 +51,8 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -321,6 +323,189 @@ public void testExternalAssetOwnerLoanProductAttributeInvalidValue() {
Assertions.assertEquals(thrownException.getMessage(), "The given attribute key or attribute value is not valid.");
}
+ @Test
+ public void testCreateExcludedTransactionTypesAttributeNormalizesTheStoredValue() {
+ TestContext testContext = new TestContext(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY,
+ "buy_down_fee, BUY_DOWN_FEE_ADJUSTMENT");
+ ArgumentCaptor loanProductAttributeArgumentCaptor = ArgumentCaptor
+ .forClass(ExternalAssetOwnerLoanProductAttributes.class);
+
+ // given
+ final JsonCommand command = createJsonCommand(testContext.jsonCommandString, testContext.loanProductId, null);
+ when(testContext.externalAssetOwnerLoanProductAttributesRepository.existsByLoanProductIdAndKey(testContext.loanProductId,
+ testContext.attributeKey)).thenReturn(false);
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(true);
+
+ // when
+ testContext.externalAssetOwnerLoanProductAttributesWriteService.createExternalAssetOwnerLoanProductAttribute(command);
+
+ // then
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture());
+ ExternalAssetOwnerLoanProductAttributes savedAttribute = loanProductAttributeArgumentCaptor.getValue();
+ Assertions.assertEquals(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY,
+ savedAttribute.getAttributeKey());
+ Assertions.assertEquals("BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT", savedAttribute.getAttributeValue());
+ }
+
+ @Test
+ public void testUpdateExcludedTransactionTypesAttributeReplacesTheValue() {
+ TestContext testContext = new TestContext(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY,
+ "BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT,BUY_DOWN_FEE_AMORTIZATION,BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT");
+ ArgumentCaptor loanProductAttributeArgumentCaptor = ArgumentCaptor
+ .forClass(ExternalAssetOwnerLoanProductAttributes.class);
+
+ ExternalAssetOwnerLoanProductAttributes attributeInDB = new ExternalAssetOwnerLoanProductAttributes();
+ attributeInDB.setLoanProductId(testContext.loanProductId);
+ attributeInDB.setAttributeKey(testContext.attributeKey);
+ attributeInDB.setAttributeValue("BUY_DOWN_FEE");
+ attributeInDB.setId(1L);
+
+ // given
+ final JsonCommand command = createJsonCommand(testContext.jsonCommandString, testContext.loanProductId, attributeInDB.getId());
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(true);
+ when(testContext.externalAssetOwnerLoanProductAttributesRepository.findById(command.entityId()))
+ .thenReturn(Optional.of(attributeInDB));
+
+ // when
+ testContext.externalAssetOwnerLoanProductAttributesWriteService.updateExternalAssetOwnerLoanProductAttribute(command,
+ testContext.attributeKey, testContext.attributeValue);
+
+ // then
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture());
+ Assertions.assertEquals(testContext.attributeValue, loanProductAttributeArgumentCaptor.getValue().getAttributeValue());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "BUY_DOWN_FEE,NOT_A_TYPE", ",", "BUY_DOWN_FEE,", "BUY_DOWN_FEE,BUY_DOWN_FEE" })
+ public void testCreateExcludedTransactionTypesAttributeWithInvalidValuePersistsNothing(String attributeValue) {
+ TestContext testContext = new TestContext(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY,
+ attributeValue);
+
+ final JsonCommand command = createJsonCommand(testContext.jsonCommandString, testContext.loanProductId, null);
+
+ assertThrows(ExternalAssetOwnerLoanProductAttributeInvalidSettlementAttributeException.class,
+ () -> testContext.externalAssetOwnerLoanProductAttributesWriteService
+ .createExternalAssetOwnerLoanProductAttribute(command));
+
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0)).saveAndFlush(any());
+ verify(testContext.loanProductRepository, times(0)).existsById(testContext.loanProductId);
+ }
+
+ @Test
+ public void testCreateExcludedTransactionTypesAttributeExceedingTheMaximumLengthIsRejected() {
+ String tooLongValue = "A".repeat(2001);
+ TestContext testContext = new TestContext(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY,
+ tooLongValue);
+
+ final JsonCommand command = createJsonCommand(testContext.jsonCommandString, testContext.loanProductId, null);
+
+ assertThrows(PlatformApiDataValidationException.class, () -> testContext.externalAssetOwnerLoanProductAttributesWriteService
+ .createExternalAssetOwnerLoanProductAttribute(command));
+
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0)).saveAndFlush(any());
+ }
+
+ @Test
+ public void testCreateExcludedTransactionTypesAttributeAcceptsTheFullListOfTransactionTypes() {
+ String allTypes = String.join(",", new ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute().getAttributeValues());
+ TestContext testContext = new TestContext(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY, allTypes);
+ ArgumentCaptor loanProductAttributeArgumentCaptor = ArgumentCaptor
+ .forClass(ExternalAssetOwnerLoanProductAttributes.class);
+
+ final JsonCommand command = createJsonCommand(testContext.jsonCommandString, testContext.loanProductId, null);
+ when(testContext.externalAssetOwnerLoanProductAttributesRepository.existsByLoanProductIdAndKey(testContext.loanProductId,
+ testContext.attributeKey)).thenReturn(false);
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(true);
+
+ testContext.externalAssetOwnerLoanProductAttributesWriteService.createExternalAssetOwnerLoanProductAttribute(command);
+
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture());
+ Assertions.assertEquals(allTypes, loanProductAttributeArgumentCaptor.getValue().getAttributeValue());
+ }
+
+ @Test
+ public void testDeleteExternalAssetOwnerLoanProductAttributeHappyPath() {
+ TestContext testContext = new TestContext(ExcludedTransactionTypesExternalAssetOwnerLoanProductAttribute.ATTRIBUTE_KEY,
+ "BUY_DOWN_FEE");
+
+ ExternalAssetOwnerLoanProductAttributes attributeInDB = new ExternalAssetOwnerLoanProductAttributes();
+ attributeInDB.setLoanProductId(testContext.loanProductId);
+ attributeInDB.setAttributeKey(testContext.attributeKey);
+ attributeInDB.setAttributeValue(testContext.attributeValue);
+ attributeInDB.setId(1L);
+
+ // given
+ final JsonCommand command = createJsonCommand(null, testContext.loanProductId, attributeInDB.getId());
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(true);
+ when(testContext.externalAssetOwnerLoanProductAttributesRepository.findById(command.entityId()))
+ .thenReturn(Optional.of(attributeInDB));
+
+ // when
+ CommandProcessingResult result = testContext.externalAssetOwnerLoanProductAttributesWriteService
+ .deleteExternalAssetOwnerLoanProductAttribute(command);
+
+ // then
+ verify(testContext.loanProductRepository).existsById(testContext.loanProductId);
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository).delete(attributeInDB);
+ Assertions.assertEquals(testContext.loanProductId, result.getResourceId());
+ }
+
+ @Test
+ public void testDeleteExternalAssetOwnerLoanProductAttributeOnAttributeThatDoesNotExist() {
+ TestContext testContext = new TestContext();
+
+ final JsonCommand command = createJsonCommand(null, testContext.loanProductId, 1L);
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(true);
+ when(testContext.externalAssetOwnerLoanProductAttributesRepository.findById(command.entityId())).thenReturn(Optional.empty());
+
+ assertThrows(ExternalAssetOwnerLoanProductAttributeNotFoundException.class,
+ () -> testContext.externalAssetOwnerLoanProductAttributesWriteService
+ .deleteExternalAssetOwnerLoanProductAttribute(command));
+
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0))
+ .delete(any(ExternalAssetOwnerLoanProductAttributes.class));
+ }
+
+ @Test
+ public void testDeleteExternalAssetOwnerLoanProductAttributeOnUnknownLoanProduct() {
+ TestContext testContext = new TestContext();
+
+ final JsonCommand command = createJsonCommand(null, testContext.loanProductId, 1L);
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(false);
+
+ assertThrows(LoanProductNotFoundException.class, () -> testContext.externalAssetOwnerLoanProductAttributesWriteService
+ .deleteExternalAssetOwnerLoanProductAttribute(command));
+
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0))
+ .delete(any(ExternalAssetOwnerLoanProductAttributes.class));
+ }
+
+ @Test
+ public void testDeleteExternalAssetOwnerLoanProductAttributeBelongingToAnotherLoanProduct() {
+ TestContext testContext = new TestContext();
+
+ ExternalAssetOwnerLoanProductAttributes attributeInDB = new ExternalAssetOwnerLoanProductAttributes();
+ attributeInDB.setLoanProductId(testContext.loanProductId + 1);
+ attributeInDB.setAttributeKey(testContext.attributeKey);
+ attributeInDB.setAttributeValue(testContext.attributeValue);
+ attributeInDB.setId(1L);
+
+ final JsonCommand command = createJsonCommand(null, testContext.loanProductId, attributeInDB.getId());
+ when(testContext.loanProductRepository.existsById(testContext.loanProductId)).thenReturn(true);
+ when(testContext.externalAssetOwnerLoanProductAttributesRepository.findById(command.entityId()))
+ .thenReturn(Optional.of(attributeInDB));
+
+ ExternalAssetOwnerLoanProductAttributesException thrownException = assertThrows(
+ ExternalAssetOwnerLoanProductAttributesException.class,
+ () -> testContext.externalAssetOwnerLoanProductAttributesWriteService
+ .deleteExternalAssetOwnerLoanProductAttribute(command));
+
+ verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0))
+ .delete(any(ExternalAssetOwnerLoanProductAttributes.class));
+ Assertions.assertEquals("The requested attribute does not belong to the loanProductId: " + testContext.loanProductId + ".",
+ thrownException.getMessage());
+ }
+
private static Stream externalAssetOwnerLoanProductAttributeApiRequestDataValidationErrors() {
return Stream.of(Arguments.of("blankAttributeValue", "SETTLEMENT_MODEL", "", "Validation errors exist."),
@@ -360,25 +545,34 @@ static class TestContext {
@Mock
private LoanProductRepository loanProductRepository;
- @InjectMocks
private ExternalAssetOwnerLoanProductAttributesWriteServiceImpl externalAssetOwnerLoanProductAttributesWriteService;
private final FromJsonHelper fromJsonHelper = new FromJsonHelper();
private final Long loanProductId = ThreadLocalRandom.current().nextLong(10, 100);
@Setter
- private String attributeKey = "SETTLEMENT_MODEL";
+ private String attributeKey;
@Setter
- private String attributeValue = "DELAYED_SETTLEMENT";
+ private String attributeValue;
- private String jsonCommandString = String.format("""
- {
- "attributeKey": "%s",
- "attributeValue": "%s"
- }
- """, attributeKey, attributeValue);
+ private String jsonCommandString;
TestContext() {
+ this("SETTLEMENT_MODEL", "DELAYED_SETTLEMENT");
+ }
+
+ TestContext(final String attributeKey, final String attributeValue) {
+ this.attributeKey = attributeKey;
+ this.attributeValue = attributeValue;
+ this.jsonCommandString = String.format("""
+ {
+ "attributeKey": "%s",
+ "attributeValue": "%s"
+ }
+ """, attributeKey, attributeValue);
MockitoAnnotations.openMocks(this);
+ this.externalAssetOwnerLoanProductAttributesWriteService = new ExternalAssetOwnerLoanProductAttributesWriteServiceImpl(
+ fromApiJsonHelper, externalAssetOwnerLoanProductAttributesRepository, loanProductRepository,
+ new ExternalAssetOwnerLoanProductAttributeProvider());
stubFromApiJsonHelper();
}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignExternalAssetOwnerHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignExternalAssetOwnerHelper.java
index 1b5a31fc88d..7d8a01a324c 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignExternalAssetOwnerHelper.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignExternalAssetOwnerHelper.java
@@ -28,19 +28,24 @@
import org.apache.fineract.accounting.common.AccountingConstants;
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
+import org.apache.fineract.client.models.CommandProcessingResult;
import org.apache.fineract.client.models.ExternalAssetOwnerRequest;
import org.apache.fineract.client.models.ExternalAssetOwnerSearchRequest;
import org.apache.fineract.client.models.ExternalOwnerJournalEntryData;
import org.apache.fineract.client.models.ExternalOwnerTransferJournalEntryData;
import org.apache.fineract.client.models.ExternalTransferData;
+import org.apache.fineract.client.models.ExternalTransferLoanProductAttributesTemplateData;
import org.apache.fineract.client.models.ExternalTransferOwnerData;
import org.apache.fineract.client.models.GetFinancialActivityAccountsResponse;
import org.apache.fineract.client.models.PageExternalTransferData;
+import org.apache.fineract.client.models.PageExternalTransferLoanProductAttributesData;
import org.apache.fineract.client.models.PagedRequestExternalAssetOwnerSearchRequest;
+import org.apache.fineract.client.models.PostExternalAssetOwnerLoanProductAttributeRequest;
import org.apache.fineract.client.models.PostExternalAssetOwnerRequest;
import org.apache.fineract.client.models.PostExternalAssetOwnerResponse;
import org.apache.fineract.client.models.PostFinancialActivityAccountsRequest;
import org.apache.fineract.client.models.PostInitiateTransferResponse;
+import org.apache.fineract.client.models.PutExternalAssetOwnerLoanProductAttributeRequest;
import org.apache.fineract.integrationtests.common.accounting.Account;
public class FeignExternalAssetOwnerHelper {
@@ -64,6 +69,35 @@ public PostExternalAssetOwnerResponse createExternalAssetOwner(PostExternalAsset
return ok(() -> fineractClient.externalAssetOwners().createExternalAssetOwner(request));
}
+ public List retrieveLoanProductAttributesTemplate() {
+ return ok(() -> fineractClient.externalAssetOwnerLoanProductAttributes().retrieveTemplateExternalAssetOwnerLoanProductAttributes());
+ }
+
+ public CommandProcessingResult createLoanProductAttribute(Long loanProductId, String attributeKey, String attributeValue) {
+ PostExternalAssetOwnerLoanProductAttributeRequest request = new PostExternalAssetOwnerLoanProductAttributeRequest()
+ .attributeKey(attributeKey).attributeValue(attributeValue);
+ return ok(() -> fineractClient.externalAssetOwnerLoanProductAttributes().createExternalAssetOwnerLoanProductAttribute(loanProductId,
+ request));
+ }
+
+ public CommandProcessingResult updateLoanProductAttribute(Long loanProductId, Long attributeId, String attributeKey,
+ String attributeValue) {
+ PutExternalAssetOwnerLoanProductAttributeRequest request = new PutExternalAssetOwnerLoanProductAttributeRequest()
+ .attributeKey(attributeKey).attributeValue(attributeValue);
+ return ok(() -> fineractClient.externalAssetOwnerLoanProductAttributes().updateExternalAssetOwnerLoanProductAttribute(loanProductId,
+ attributeId, request));
+ }
+
+ public CommandProcessingResult deleteLoanProductAttribute(Long loanProductId, Long attributeId) {
+ return ok(() -> fineractClient.externalAssetOwnerLoanProductAttributes().deleteExternalAssetOwnerLoanProductAttribute(loanProductId,
+ attributeId));
+ }
+
+ public PageExternalTransferLoanProductAttributesData retrieveLoanProductAttributes(Long loanProductId, String attributeKey) {
+ return ok(() -> fineractClient.externalAssetOwnerLoanProductAttributes()
+ .retrieveAllExternalAssetOwnerLoanProductAttributes(loanProductId, attributeKey));
+ }
+
public List retrieveExternalAssetOwners() {
return ok(() -> fineractClient.externalAssetOwners().retrieveExternalAssetOwners());
}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerLoanProductAttributesTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerLoanProductAttributesTest.java
new file mode 100644
index 00000000000..199096f46e5
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/ExternalAssetOwnerLoanProductAttributesTest.java
@@ -0,0 +1,139 @@
+/**
+ * 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.investor.externalassetowner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
+import org.apache.fineract.client.models.ExternalTransferLoanProductAttributesData;
+import org.apache.fineract.client.models.ExternalTransferLoanProductAttributesTemplateData;
+import org.apache.fineract.client.models.PageExternalTransferLoanProductAttributesData;
+import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignExternalAssetOwnerHelper;
+import org.apache.fineract.integrationtests.common.FineractFeignClientHelper;
+import org.junit.jupiter.api.Test;
+
+public class ExternalAssetOwnerLoanProductAttributesTest extends FeignLoanTestBase {
+
+ private static final String EXCLUDED_TRANSACTION_TYPES = "EXCLUDED_TRANSACTION_TYPES";
+ private static final String SETTLEMENT_MODEL = "SETTLEMENT_MODEL";
+ private static final String BUY_DOWN_FEE_TYPES = "BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT,BUY_DOWN_FEE_AMORTIZATION,BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT";
+
+ private final FeignExternalAssetOwnerHelper externalAssetOwnerHelper = new FeignExternalAssetOwnerHelper(
+ FineractFeignClientHelper.getFineractFeignClient());
+
+ @Test
+ public void testExcludedTransactionTypesAttributeCreateUpdateDeleteLifecycle() {
+ final Long loanProductId = createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct());
+
+ // create
+ final Long createdProductId = externalAssetOwnerHelper
+ .createLoanProductAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES, BUY_DOWN_FEE_TYPES).getResourceId();
+ assertEquals(loanProductId, createdProductId);
+
+ // read back, verbatim
+ final ExternalTransferLoanProductAttributesData created = retrieveSingleAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES);
+ assertNotNull(created.getAttributeId());
+ assertEquals(loanProductId, created.getLoanProductId());
+ assertEquals(EXCLUDED_TRANSACTION_TYPES, created.getAttributeKey());
+ assertEquals(BUY_DOWN_FEE_TYPES, created.getAttributeValue());
+
+ // update, with loose casing and padding that must be normalised
+ final Long attributeId = created.getAttributeId();
+ externalAssetOwnerHelper.updateLoanProductAttribute(loanProductId, attributeId, EXCLUDED_TRANSACTION_TYPES,
+ "buy_down_fee, BUY_DOWN_FEE_AMORTIZATION");
+
+ final ExternalTransferLoanProductAttributesData updated = retrieveSingleAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES);
+ assertEquals(attributeId, updated.getAttributeId());
+ assertEquals("BUY_DOWN_FEE,BUY_DOWN_FEE_AMORTIZATION", updated.getAttributeValue());
+
+ // delete, the product falls back to excluding nothing
+ final Long deletedProductId = externalAssetOwnerHelper.deleteLoanProductAttribute(loanProductId, attributeId).getResourceId();
+ assertEquals(loanProductId, deletedProductId);
+
+ final PageExternalTransferLoanProductAttributesData afterDelete = externalAssetOwnerHelper
+ .retrieveLoanProductAttributes(loanProductId, EXCLUDED_TRANSACTION_TYPES);
+ assertEquals(0, afterDelete.getTotalFilteredRecords());
+
+ // the key can be configured again afterwards
+ externalAssetOwnerHelper.createLoanProductAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES, "BUY_DOWN_FEE");
+ assertEquals("BUY_DOWN_FEE", retrieveSingleAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES).getAttributeValue());
+ }
+
+ @Test
+ public void testTemplateContainsExcludedTransactionTypesAndSettlementModel() {
+ final List template = externalAssetOwnerHelper
+ .retrieveLoanProductAttributesTemplate();
+
+ final ExternalTransferLoanProductAttributesTemplateData excludedTransactionTypes = template.stream()
+ .filter(attribute -> EXCLUDED_TRANSACTION_TYPES.equals(attribute.getAttributeKey())).findFirst().orElseThrow();
+ assertEquals(Boolean.TRUE, excludedTransactionTypes.getMultiValue());
+ assertNotNull(excludedTransactionTypes.getAttributeValues());
+
+ assertTrue(excludedTransactionTypes.getAttributeValues().contains("BUY_DOWN_FEE"));
+ assertTrue(excludedTransactionTypes.getAttributeValues().contains("BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT"));
+ assertFalse(excludedTransactionTypes.getAttributeValues().contains("INVALID"));
+
+ final ExternalTransferLoanProductAttributesTemplateData settlementModel = template.stream()
+ .filter(attribute -> SETTLEMENT_MODEL.equals(attribute.getAttributeKey())).findFirst().orElseThrow();
+ assertEquals(Boolean.FALSE, settlementModel.getMultiValue());
+ assertEquals(List.of("DEFAULT_SETTLEMENT", "DELAYED_SETTLEMENT"), settlementModel.getAttributeValues());
+ }
+
+ @Test
+ public void testCreateExcludedTransactionTypesAttributeWithUnknownTransactionTypeIsRejected() {
+ final Long loanProductId = createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct());
+
+ final CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, () -> externalAssetOwnerHelper
+ .createLoanProductAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES, "BUY_DOWN_FEE,NOT_A_TYPE"));
+ assertTrue(exception.getMessage().contains("error.msg.externalAssetOwnerLoanProductAttribute.invalidSettlementAttribute"));
+
+ final PageExternalTransferLoanProductAttributesData attributes = externalAssetOwnerHelper
+ .retrieveLoanProductAttributes(loanProductId, EXCLUDED_TRANSACTION_TYPES);
+ assertEquals(0, attributes.getTotalFilteredRecords());
+ }
+
+ @Test
+ public void testDeleteExcludedTransactionTypesAttributeOfAnotherLoanProductIsRejected() {
+ final Long loanProductId = createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct());
+ final Long otherLoanProductId = createLoanProduct(createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct());
+
+ externalAssetOwnerHelper.createLoanProductAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES, "BUY_DOWN_FEE");
+ final Long attributeId = retrieveSingleAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES).getAttributeId();
+
+ final CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
+ () -> externalAssetOwnerHelper.deleteLoanProductAttribute(otherLoanProductId, attributeId));
+ assertTrue(exception.getMessage().contains("error.msg.externalAssetOwnerLoanProductAttributes.general"));
+
+ // the attribute of the original loan product is untouched
+ assertEquals("BUY_DOWN_FEE", retrieveSingleAttribute(loanProductId, EXCLUDED_TRANSACTION_TYPES).getAttributeValue());
+ }
+
+ private ExternalTransferLoanProductAttributesData retrieveSingleAttribute(final Long loanProductId, final String attributeKey) {
+ final PageExternalTransferLoanProductAttributesData attributes = externalAssetOwnerHelper
+ .retrieveLoanProductAttributes(loanProductId, attributeKey);
+ assertEquals(1, attributes.getTotalFilteredRecords());
+ return attributes.getPageItems().getFirst();
+ }
+}