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/CapitalizedIncomeAmortizationStrategyExternalAssetOwnerLoanProductAttribute.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/CapitalizedIncomeAmortizationStrategyExternalAssetOwnerLoanProductAttribute.java new file mode 100644 index 00000000000..7f506040e3d --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/CapitalizedIncomeAmortizationStrategyExternalAssetOwnerLoanProductAttribute.java @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.investor.data.attribute; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +public enum CapitalizedIncomeAmortizationStrategyExternalAssetOwnerLoanProductAttribute implements ExternalAssetOwnerLoanProductAttribute { + + DEFERRED, // + IMMEDIATE; // + + private final String attributeKey; + + CapitalizedIncomeAmortizationStrategyExternalAssetOwnerLoanProductAttribute() { + this.attributeKey = "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY"; + } + + @Override + public String getAttributeKey() { + return attributeKey; + } + + @Override + public String getAttributeValue() { + return name(); + } + + @Override + public List getAttributeValues() { + return Arrays.stream(values()).map(Enum::name).toList(); + } + + @Override + public boolean validate(String attributeValue) { + return this.getAttributeValue().equals(attributeValue.toUpperCase(Locale.ROOT)); + } + + @Override + public boolean isMultiValue() { + return false; + } + + @Override + public String normalize(String token) { + return token.trim().toUpperCase(Locale.ROOT); + } + +} 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..f86bbc1e4b5 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 @@ -110,11 +114,30 @@ public void testRetrieveExternalAssetOwnerLoanProductAttributesTemplate() { .retrieveExternalAssetOwnerLoanProductAttributesTemplate(); // then + // verify available attribute size + assertEquals(3, result.size()); + + // verify SETTLEMENT_MODEL ExternalTransferLoanProductAttributesTemplateData settlementModel = result.stream() .filter(attribute -> "SETTLEMENT_MODEL".equals(attribute.getAttributeKey())).findFirst().orElseThrow(); - assertEquals(1, result.size()); assertEquals(List.of("DEFAULT_SETTLEMENT", "DELAYED_SETTLEMENT"), settlementModel.getAttributeValues()); assertFalse(settlementModel.isMultiValue()); + + // verify EXCLUDED_TRANSACTION_TYPES + 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()); + + // verify CAPITALIZED_INCOME_AMORTIZATION_STRATEGY + ExternalTransferLoanProductAttributesTemplateData capitalizedIncomeAmortizationStrategy = result.stream() + .filter(attribute -> "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY".equals(attribute.getAttributeKey())).findFirst() + .orElseThrow(); + assertEquals(List.of("DEFERRED", "IMMEDIATE"), capitalizedIncomeAmortizationStrategy.getAttributeValues()); + assertFalse(capitalizedIncomeAmortizationStrategy.isMultiValue()); } @Test @@ -178,6 +201,7 @@ public void testRetrieveLoanProductAttributesDataByLoanProductIdNotFound() { } private static Stream testRetrieveAllLoanProductAttributesByLoanProductIdDataProvider() { - return Stream.of(Arguments.of(1L, "SETTLEMENT_MODEL"), Arguments.of(1L, null)); + return Stream.of(Arguments.of(1L, "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY"), Arguments.of(1L, "SETTLEMENT_MODEL"), + Arguments.of(1L, null)); } } 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..35ea00db042 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; @@ -48,9 +50,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; @@ -61,28 +63,6 @@ @MockitoSettings(strictness = Strictness.LENIENT) public class ExternalAssetOwnerLoanProductAttributesWriteServiceImplTest { - @Test - public void testCreateExternalAssetOwnerLoanProductAttributeHappyPath() { - TestContext testContext = new TestContext(); - 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).existsByLoanProductIdAndKey(any(), any()); - verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture()); - verify(testContext.loanProductRepository).existsById(testContext.loanProductId); - assertLoanProductAttributeValues(testContext, loanProductAttributeArgumentCaptor.getValue()); - } - @Test public void testUpdateExternalAssetOwnerLoanProductAttributeHappyPath() { TestContext testContext = new TestContext(); @@ -110,16 +90,22 @@ public void testUpdateExternalAssetOwnerLoanProductAttributeHappyPath() { verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture()); } - @Test - public void testUpdateExternalAssetOwnerLoanProductAttributeUpdateNotRequired() { - TestContext testContext = new TestContext(); + @ParameterizedTest + @CsvSource(value = { "SETTLEMENT_MODEL|DELAYED_SETTLEMENT|DELAYED_SETTLEMENT", + "EXCLUDED_TRANSACTION_TYPES|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT", + "EXCLUDED_TRANSACTION_TYPES|buy_down_fee, BUY_DOWN_FEE_ADJUSTMENT|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|DEFerrED|DEFERRED", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|IMMEDIATE|IMMEDIATE" }, delimiter = '|') + public void testUpdateExternalAssetOwnerLoanProductAttributeUpdateNotRequired(String attributeKey, String attributeValue, + String currentValue) { + TestContext testContext = new TestContext(attributeKey, attributeValue); ArgumentCaptor loanProductAttributeArgumentCaptor = ArgumentCaptor .forClass(ExternalAssetOwnerLoanProductAttributes.class); ExternalAssetOwnerLoanProductAttributes attributeInDB = new ExternalAssetOwnerLoanProductAttributes(); attributeInDB.setLoanProductId(testContext.loanProductId); attributeInDB.setAttributeKey(testContext.attributeKey); - attributeInDB.setAttributeValue(testContext.attributeValue); + attributeInDB.setAttributeValue(currentValue); attributeInDB.setId(1L); // given @@ -138,6 +124,39 @@ public void testUpdateExternalAssetOwnerLoanProductAttributeUpdateNotRequired() .saveAndFlush(loanProductAttributeArgumentCaptor.capture()); } + /** + * create test case test case-insensitive validation test case-sensitive validation success + */ + @ParameterizedTest + @CsvSource(value = { "SETTLEMENT_MODEL|DELAYED_SETTLEMENT|DELAYED_SETTLEMENT", "SETTLEMENT_MODEL|DEFAULT_SETTLEMENT|DEFAULT_SETTLEMENT", + "EXCLUDED_TRANSACTION_TYPES|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT", + "EXCLUDED_TRANSACTION_TYPES|buy_down_fee, BUY_DOWN_FEE_ADJUSTMENT|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|DEFERRED|DEFERRED", "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|DEFerrED|DEFERRED", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|IMMEDIATE|IMMEDIATE" }, delimiter = '|') + public void testCreateSuccess(String attributeKey, String attributeValue, String expectedSavedValue) { + TestContext testContext = new TestContext(attributeKey, attributeValue); + + 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).existsByLoanProductIdAndKey(any(), any()); + verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture()); + verify(testContext.loanProductRepository).existsById(testContext.loanProductId); + ExternalAssetOwnerLoanProductAttributes savedAttribute = loanProductAttributeArgumentCaptor.getValue(); + Assertions.assertEquals(attributeKey, savedAttribute.getAttributeKey()); + Assertions.assertEquals(expectedSavedValue, savedAttribute.getAttributeValue()); + } + @Test public void testUpdateExternalAssetOwnerLoanProductAttributeOnAttributeThatDoesNotExist() { TestContext testContext = new TestContext(); @@ -159,7 +178,7 @@ public void testUpdateExternalAssetOwnerLoanProductAttributeOnAttributeThatDoesN verify(testContext.externalAssetOwnerLoanProductAttributesRepository).findById(1L); verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0)) .saveAndFlush(loanProductAttributeArgumentCaptor.capture()); - Assertions.assertEquals(thrownException.getMessage(), "Loan product attribute with id " + 1L + " was not found"); + Assertions.assertEquals("Loan product attribute with id " + 1L + " was not found", thrownException.getMessage()); } @Test @@ -190,34 +209,8 @@ public void testUpdateExternalAssetOwnerLoanProductAttributeOnAttributeWithDiffe verify(testContext.externalAssetOwnerLoanProductAttributesRepository).findById(command.entityId()); verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0)) .saveAndFlush(loanProductAttributeArgumentCaptor.capture()); - Assertions.assertEquals(thrownException.getMessage(), - "The attribute key of requested update attribute does not match the attribute key from database."); - } - - @Test - public void testCreateExternalAssetOwnerLoanProductAttributeUsingDefaultSettlementValue() { - TestContext testContext = new TestContext(); - ArgumentCaptor loanProductAttributeArgumentCaptor = ArgumentCaptor - .forClass(ExternalAssetOwnerLoanProductAttributes.class); - - // given - final JsonElement jsonCommandElement = testContext.fromJsonHelper.parse(testContext.jsonCommandString); - 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.fromApiJsonHelper.extractStringNamed(ExternalAssetOwnerLoanProductAttributeRequestParameters.ATTRIBUTE_VALUE, - jsonCommandElement)).thenReturn("DEFAULT_SETTLEMENT"); - testContext.setAttributeValue("DEFAULT_SETTLEMENT"); - - // when - testContext.externalAssetOwnerLoanProductAttributesWriteService.createExternalAssetOwnerLoanProductAttribute(command); - - // then - verify(testContext.externalAssetOwnerLoanProductAttributesRepository).existsByLoanProductIdAndKey(any(), any()); - verify(testContext.externalAssetOwnerLoanProductAttributesRepository).saveAndFlush(loanProductAttributeArgumentCaptor.capture()); - verify(testContext.loanProductRepository).existsById(testContext.loanProductId); - assertLoanProductAttributeValues(testContext, loanProductAttributeArgumentCaptor.getValue()); + Assertions.assertEquals("The attribute key of requested update attribute does not match the attribute key from database.", + thrownException.getMessage()); } @ParameterizedTest @@ -301,15 +294,16 @@ public void testExternalAssetOwnerLoanProductAttributeInvalidKey() { Assertions.assertEquals(thrownException.getMessage(), "The given attribute key or attribute value is not valid."); } - @Test - public void testExternalAssetOwnerLoanProductAttributeInvalidValue() { - TestContext testContext = new TestContext(); + @ParameterizedTest + @CsvSource(value = { "SETTLEMENT_MODEL|BAD_VALUE", "EXCLUDED_TRANSACTION_TYPES|BAD_VALUE", + "EXCLUDED_TRANSACTION_TYPES|BUY_DOWN_FEE,NOT_A_TYPE", "EXCLUDED_TRANSACTION_TYPES|,", + "EXCLUDED_TRANSACTION_TYPES|BUY_DOWN_FEE,", "EXCLUDED_TRANSACTION_TYPES|BUY_DOWN_FEE,BUY_DOWN_FEE", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|BAD_VALUE" }, delimiter = '|') + public void testExternalAssetOwnerLoanProductAttributeInvalidValue(String attributeKey, String attributeValue) { + TestContext testContext = new TestContext(attributeKey, attributeValue); final JsonCommand command = createJsonCommand(testContext.jsonCommandString, testContext.loanProductId, null); - final JsonElement jsonCommandElement = testContext.fromJsonHelper.parse(testContext.jsonCommandString); - when(testContext.fromApiJsonHelper.extractStringNamed(ExternalAssetOwnerLoanProductAttributeRequestParameters.ATTRIBUTE_VALUE, - jsonCommandElement)).thenReturn("BAD_VALUE"); ExternalAssetOwnerLoanProductAttributeInvalidSettlementAttributeException thrownException = assertThrows( ExternalAssetOwnerLoanProductAttributeInvalidSettlementAttributeException.class, () -> testContext.externalAssetOwnerLoanProductAttributesWriteService @@ -318,20 +312,162 @@ public void testExternalAssetOwnerLoanProductAttributeInvalidValue() { verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0)).saveAndFlush(any()); verify(testContext.externalAssetOwnerLoanProductAttributesRepository, times(0)).existsByLoanProductIdAndKey(any(), any()); verify(testContext.loanProductRepository, times(0)).existsById(testContext.loanProductId); - Assertions.assertEquals(thrownException.getMessage(), "The given attribute key or attribute value is not valid."); + Assertions.assertEquals("The given attribute key or attribute value is not valid.", thrownException.getMessage()); } - private static Stream externalAssetOwnerLoanProductAttributeApiRequestDataValidationErrors() { + @ParameterizedTest + @CsvSource(value = { "SETTLEMENT_MODEL|DELAYED_SETTLEMENT|DEFAULT_SETTLEMENT", "SETTLEMENT_MODEL|DEFAULT_SETTLEMENT|DELAYED_SETTLEMENT", + "EXCLUDED_TRANSACTION_TYPES|BUY_DOWN_FEE,BUY_DOWN_FEE_ADJUSTMENT,BUY_DOWN_FEE_AMORTIZATION,BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT|BUY_DOWN_FEE", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|DEFERRED|IMMEDIATE", + "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY|IMMEDIATE|DEFERRED" }, delimiter = '|') + public void testUpdateExcludedTransactionTypesAttributeReplacesTheValue(String attributeKey, String attributeValue, + String currentValue) { + TestContext testContext = new TestContext(attributeKey, attributeValue); + ArgumentCaptor loanProductAttributeArgumentCaptor = ArgumentCaptor + .forClass(ExternalAssetOwnerLoanProductAttributes.class); - return Stream.of(Arguments.of("blankAttributeValue", "SETTLEMENT_MODEL", "", "Validation errors exist."), - Arguments.of("blankAttributeKey", "", "DELAYED_SETTLEMENT", "Validation errors exist.")); + ExternalAssetOwnerLoanProductAttributes attributeInDB = new ExternalAssetOwnerLoanProductAttributes(); + attributeInDB.setLoanProductId(testContext.loanProductId); + attributeInDB.setAttributeKey(testContext.attributeKey); + attributeInDB.setAttributeValue(currentValue); + 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()); + } + + @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()); } - private void assertLoanProductAttributeValues(final TestContext testContext, - final ExternalAssetOwnerLoanProductAttributes loanProductAttribute) { - Assertions.assertEquals(testContext.loanProductId, loanProductAttribute.getLoanProductId()); - Assertions.assertEquals(testContext.attributeKey, loanProductAttribute.getAttributeKey()); - Assertions.assertEquals(testContext.attributeValue, loanProductAttribute.getAttributeValue()); + @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."), + Arguments.of("blankAttributeKey", "", "DELAYED_SETTLEMENT", "Validation errors exist."), + Arguments.of("blankAttributeKey", "CAPITALIZED_INCOME_AMORTIZATION_STRATEGY", "", "Validation errors exist."), + Arguments.of("blankAttributeKey", "EXCLUDED_TRANSACTION_TYPES", "", "Validation errors exist.")); } /** @@ -360,25 +496,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(); + } +}