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-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java index 6baacc0371e..f8afa9cf198 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/AssetExternalizationErrorMessage.java @@ -32,6 +32,11 @@ public enum AssetExternalizationErrorMessage { LOAN_SUBMITTED_AND_PENDING_APPROVAL_INVALID("Loan status SUBMITTED_AND_PENDING_APPROVAL is not valid for transfer."), // LOAN_APPROVED_INVALID("Loan status APPROVED is not valid for transfer."), // ALREADY_IN_PROGRESS("This loan cannot be sold, there is already an in progress transfer"), // + LOAN_PRODUCT_ATTRIBUTE_ALREADY_EXISTS("attributeKey already exists for the loanProductId"), // + LOAN_PRODUCT_ATTRIBUTE_INVALID("The given attribute key or attribute value is not valid."), // + LOAN_PRODUCT_NOT_FOUND("Loan product with identifier"), // + USER_HAS_NO_CREATE_ATTRIBUTE_AUTHORITY("User has no authority to: CREATE_EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE"), // + USER_HAS_NO_UPDATE_ATTRIBUTE_AUTHORITY("User has no authority to: UPDATE_EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE"), // INVALID_REQUEST("The request was invalid. This typically will happen due to validation errors which are provided."); // public final String value; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/assetexternalization/AssetExternalizationStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/assetexternalization/AssetExternalizationStepDef.java index 5ad063db5c3..b0fc1958265 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/assetexternalization/AssetExternalizationStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/assetexternalization/AssetExternalizationStepDef.java @@ -32,17 +32,20 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.feign.services.ExternalAssetOwnerLoanProductAttributesApi; import org.apache.fineract.client.feign.services.ExternalAssetOwnersApi; import org.apache.fineract.client.feign.services.LoanProductsApi; 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.ExternalOwnerJournalEntryData; import org.apache.fineract.client.models.ExternalOwnerTransferJournalEntryData; import org.apache.fineract.client.models.ExternalTransferData; import org.apache.fineract.client.models.ExternalTransferLoanProductAttributesData; +import org.apache.fineract.client.models.ExternalTransferLoanProductAttributesTemplateData; import org.apache.fineract.client.models.ExternalTransferOwnerData; import org.apache.fineract.client.models.GetLoanProductsResponse; import org.apache.fineract.client.models.JournalEntryData; @@ -52,9 +55,12 @@ import org.apache.fineract.client.models.PostExternalAssetOwnerRequest; import org.apache.fineract.client.models.PostExternalAssetOwnerResponse; import org.apache.fineract.client.models.PostInitiateTransferResponse; +import org.apache.fineract.client.models.PostLoanProductsResponse; import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.models.PutExternalAssetOwnerLoanProductAttributeRequest; +import org.apache.fineract.test.api.FineractClientConfiguration; import org.apache.fineract.test.data.AssetExternalizationErrorMessage; +import org.apache.fineract.test.factory.LoanProductsRequestFactory; import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.helper.Utils; import org.apache.fineract.test.messaging.EventAssertion; @@ -62,9 +68,9 @@ import org.apache.fineract.test.messaging.event.assetexternalization.LoanOwnershipTransferEvent; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; -import org.springframework.beans.factory.annotation.Autowired; @Slf4j +@RequiredArgsConstructor public class AssetExternalizationStepDef extends AbstractStepDef { public static final String OWNER_EXTERNAL_ID_PREFIX = "TestOwner-"; @@ -75,15 +81,13 @@ public class AssetExternalizationStepDef extends AbstractStepDef { public static final String TRANSACTION_TYPE_INTERMEDIARY_SALE = "intermediarySale"; public static final String COMMAND = "command"; public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT_ASSET_EXT); + private static final long NON_EXISTING_LOAN_PRODUCT_ID = 999_999_999L; - @Autowired - private FineractFeignClient fineractFeignClient; - - @Autowired - private EventCheckHelper eventCheckHelper; - - @Autowired - private EventAssertion eventAssertion; + private final FineractFeignClient fineractFeignClient; + private final EventCheckHelper eventCheckHelper; + private final EventAssertion eventAssertion; + private final FineractClientConfiguration fineractClientConfiguration; + private final LoanProductsRequestFactory loanProductsRequestFactory; private ExternalAssetOwnersApi externalAssetOwnersApi() { return fineractFeignClient.externalAssetOwners(); @@ -1021,4 +1025,168 @@ public void retrieveAllExternalAssetOwners() { assertThat(owners).as("Owners list should not be null").isNotNull(); } + @When("Admin creates a new loan product for external asset owner loan product attributes") + public void createLoanProductForExternalAssetOwnerLoanProductAttributes() { + final PostLoanProductsResponse response = ok( + () -> loanProductsApi().createLoanProduct(loanProductsRequestFactory.defaultLoanProductsRequestLP1())); + testContext().set(TestContextKey.EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE_LOAN_PRODUCT_ID, response.getResourceId()); + } + + @Then("External asset owner loan product attributes template contains the following attributes:") + public void checkExternalAssetOwnerLoanProductAttributesTemplate(final DataTable table) { + final List template = ok( + () -> externalAssetOwnerLoanProductAttributesApi().retrieveTemplateExternalAssetOwnerLoanProductAttributes()); + + table.asMaps().forEach(expectedAttribute -> { + final String attributeKey = expectedAttribute.get("attributeKey"); + final ExternalTransferLoanProductAttributesTemplateData actualAttribute = template.stream() + .filter(attribute -> attributeKey.equals(attribute.getAttributeKey())).findFirst() + .orElseThrow(() -> new IllegalStateException(String.format("No attribute %s is found in the template", attributeKey))); + + assertThat(actualAttribute.getAttributeValues()).as("Values of attribute %s in the template", attributeKey) + .isEqualTo(List.of(expectedAttribute.get("attributeValues").split(",", -1))); + assertThat(actualAttribute.getMultiValue()).as("Multi value flag of attribute %s in the template", attributeKey) + .isEqualTo(Boolean.parseBoolean(expectedAttribute.get("multiValue"))); + }); + } + + @When("Admin creates external asset owner loan product attribute {string} with value {string} for the new loan product") + public void createExternalAssetOwnerLoanProductAttribute(final String attributeKey, final String attributeValue) { + final Long loanProductId = newLoanProductId(); + + final CommandProcessingResult result = ok(() -> externalAssetOwnerLoanProductAttributesApi() + .createExternalAssetOwnerLoanProductAttribute(loanProductId, createAttributeRequest(attributeKey, attributeValue))); + + assertThat(result.getResourceId()).as("Resource id of the created attribute %s", attributeKey).isEqualTo(loanProductId); + } + + @When("Admin updates external asset owner loan product attribute {string} of the new loan product to value {string}") + public void updateExternalAssetOwnerLoanProductAttribute(final String attributeKey, final String attributeValue) { + final Long loanProductId = newLoanProductId(); + final Long attributeId = retrieveAttributeOfNewLoanProduct(attributeKey).getAttributeId(); + + ok(() -> externalAssetOwnerLoanProductAttributesApi().updateExternalAssetOwnerLoanProductAttribute(loanProductId, attributeId, + updateAttributeRequest(attributeKey, attributeValue))); + } + + @Then("External asset owner loan product attribute {string} of the new loan product has value {string}") + public void checkExternalAssetOwnerLoanProductAttributeValue(final String attributeKey, final String attributeValue) { + final Long loanProductId = newLoanProductId(); + final PageExternalTransferLoanProductAttributesData attributes = retrieveAttributesOfNewLoanProduct(attributeKey); + + assertThat(attributes.getTotalFilteredRecords()).as("Number of attributes %s of loan product %s", attributeKey, loanProductId) + .isOne(); + final ExternalTransferLoanProductAttributesData attribute = attributes.getPageItems().stream().findFirst().orElseThrow(); + assertThat(attribute.getAttributeId()).as("Id of attribute %s", attributeKey).isNotNull(); + assertThat(attribute.getAttributeKey()).as("Key of attribute %s", attributeKey).isEqualTo(attributeKey); + assertThat(attribute.getAttributeValue()).as("Value of attribute %s", attributeKey).isEqualTo(attributeValue); + assertThat(attribute.getLoanProductId()).as("Loan product id of attribute %s", attributeKey).isEqualTo(loanProductId); + } + + @Then("External asset owner loan product attribute {string} of the new loan product does not exist") + public void checkExternalAssetOwnerLoanProductAttributeDoesNotExist(final String attributeKey) { + assertThat(retrieveAttributesOfNewLoanProduct(attributeKey).getTotalFilteredRecords()) + .as("Number of attributes %s of loan product %s", attributeKey, newLoanProductId()).isZero(); + } + + @Then("Creating external asset owner loan product attribute {string} with value {string} for the new loan product results a {int} error and {string} error message") + public void createExternalAssetOwnerLoanProductAttributeFails(final String attributeKey, final String attributeValue, + final int errorCodeExpected, final String errorMessageType) { + assertExternalAssetOwnerLoanProductAttributeCreationFails(externalAssetOwnerLoanProductAttributesApi(), newLoanProductId(), + attributeKey, attributeValue, errorCodeExpected, errorMessageType); + } + + @Then("Created user creating external asset owner loan product attribute {string} with value {string} for the new loan product results a {int} error and {string} error message") + public void createExternalAssetOwnerLoanProductAttributeByCreatedUserFails(final String attributeKey, final String attributeValue, + final int errorCodeExpected, final String errorMessageType) { + assertExternalAssetOwnerLoanProductAttributeCreationFails(createdUserExternalAssetOwnerLoanProductAttributesApi(), + newLoanProductId(), attributeKey, attributeValue, errorCodeExpected, errorMessageType); + } + + @Then("Creating external asset owner loan product attribute {string} with value {string} for non-existing loan product results a {int} error and {string} error message") + public void createExternalAssetOwnerLoanProductAttributeForNonExistingLoanProductFails(final String attributeKey, + final String attributeValue, final int errorCodeExpected, final String errorMessageType) { + assertExternalAssetOwnerLoanProductAttributeCreationFails(externalAssetOwnerLoanProductAttributesApi(), + NON_EXISTING_LOAN_PRODUCT_ID, attributeKey, attributeValue, errorCodeExpected, errorMessageType); + } + + @Then("Updating external asset owner loan product attribute {string} of the new loan product to value {string} results a {int} error and {string} error message") + public void updateExternalAssetOwnerLoanProductAttributeFails(final String attributeKey, final String attributeValue, + final int errorCodeExpected, final String errorMessageType) { + assertExternalAssetOwnerLoanProductAttributeUpdateFails(externalAssetOwnerLoanProductAttributesApi(), attributeKey, attributeValue, + errorCodeExpected, errorMessageType); + } + + @Then("Created user updating external asset owner loan product attribute {string} of the new loan product to value {string} results a {int} error and {string} error message") + public void updateExternalAssetOwnerLoanProductAttributeByCreatedUserFails(final String attributeKey, final String attributeValue, + final int errorCodeExpected, final String errorMessageType) { + assertExternalAssetOwnerLoanProductAttributeUpdateFails(createdUserExternalAssetOwnerLoanProductAttributesApi(), attributeKey, + attributeValue, errorCodeExpected, errorMessageType); + } + + private void assertExternalAssetOwnerLoanProductAttributeCreationFails(final ExternalAssetOwnerLoanProductAttributesApi attributesApi, + final Long loanProductId, final String attributeKey, final String attributeValue, final int errorCodeExpected, + final String errorMessageType) { + final CallFailedRuntimeException exception = fail(() -> attributesApi.createExternalAssetOwnerLoanProductAttribute(loanProductId, + createAttributeRequest(attributeKey, attributeValue))); + + assertExternalAssetOwnerLoanProductAttributeError(exception, errorCodeExpected, errorMessageType); + } + + private void assertExternalAssetOwnerLoanProductAttributeUpdateFails(final ExternalAssetOwnerLoanProductAttributesApi attributesApi, + final String attributeKey, final String attributeValue, final int errorCodeExpected, final String errorMessageType) { + final Long loanProductId = newLoanProductId(); + final Long attributeId = retrieveAttributeOfNewLoanProduct(attributeKey).getAttributeId(); + + final CallFailedRuntimeException exception = fail(() -> attributesApi.updateExternalAssetOwnerLoanProductAttribute(loanProductId, + attributeId, updateAttributeRequest(attributeKey, attributeValue))); + + assertExternalAssetOwnerLoanProductAttributeError(exception, errorCodeExpected, errorMessageType); + } + + private void assertExternalAssetOwnerLoanProductAttributeError(final CallFailedRuntimeException exception, final int errorCodeExpected, + final String errorMessageType) { + final String errorMessageExpected = AssetExternalizationErrorMessage.valueOf(errorMessageType).getValue(); + final int errorCodeActual = exception.getStatus(); + final String errorMessageActual = exception.getDeveloperMessage(); + + assertThat(errorCodeActual).as(ErrorMessageHelper.wrongErrorCode(errorCodeActual, errorCodeExpected)).isEqualTo(errorCodeExpected); + assertThat(errorMessageActual).as(ErrorMessageHelper.wrongErrorMessage(errorMessageActual, errorMessageExpected)) + .contains(errorMessageExpected); + + logErrorDetails(errorCodeActual, errorMessageActual); + } + + private Long newLoanProductId() { + return testContext().get(TestContextKey.EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE_LOAN_PRODUCT_ID); + } + + private ExternalAssetOwnerLoanProductAttributesApi createdUserExternalAssetOwnerLoanProductAttributesApi() { + final String username = testContext().get(TestContextKey.CREATED_SIMPLE_USER_USERNAME); + final String password = testContext().get(TestContextKey.CREATED_SIMPLE_USER_PASSWORD); + return fineractClientConfiguration.fineractFeignClientForUser(username, password).externalAssetOwnerLoanProductAttributes(); + } + + private PageExternalTransferLoanProductAttributesData retrieveAttributesOfNewLoanProduct(final String attributeKey) { + final Long loanProductId = newLoanProductId(); + return ok(() -> externalAssetOwnerLoanProductAttributesApi().retrieveAllExternalAssetOwnerLoanProductAttributes(loanProductId, + attributeKey)); + } + + private ExternalTransferLoanProductAttributesData retrieveAttributeOfNewLoanProduct(final String attributeKey) { + return retrieveAttributesOfNewLoanProduct(attributeKey).getPageItems().stream().findFirst() + .orElseThrow(() -> new IllegalStateException( + ErrorMessageHelper.wrongDataInExternalAssetOwnerLoanProductAttribute(attributeKey, newLoanProductId()))); + } + + private PostExternalAssetOwnerLoanProductAttributeRequest createAttributeRequest(final String attributeKey, + final String attributeValue) { + return new PostExternalAssetOwnerLoanProductAttributeRequest().attributeKey(attributeKey).attributeValue(attributeValue); + } + + private PutExternalAssetOwnerLoanProductAttributeRequest updateAttributeRequest(final String attributeKey, + final String attributeValue) { + return new PutExternalAssetOwnerLoanProductAttributeRequest().attributeKey(attributeKey).attributeValue(attributeValue); + } + } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index cd9cf1fc993..40efa8d3f2e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -273,6 +273,7 @@ public abstract class TestContextKey { public static final String ASSET_EXTERNALIZATION_PREVIOUS_OWNER_EXTERNAL_ID = "assetExternalizationPreviousOwnerExternalId"; public static final String EXTERNAL_ASSET_OWNER_CREATE_RESPONSE = "externalAssetOwnerCreateResponse"; public static final String EXTERNAL_ASSET_OWNER_EXTERNAL_ID = "externalAssetOwnerExternalId"; + public static final String EXTERNAL_ASSET_OWNER_LOAN_PRODUCT_ATTRIBUTE_LOAN_PRODUCT_ID = "externalAssetOwnerLoanProductAttributeLoanProductId"; public static final String TRANSACTION_EVENT = "transactionEvent"; public static final String LOAN_WRITE_OFF_RESPONSE = "loanWriteOffResponse"; public static final String LOAN_DELINQUENCY_ACTION_RESPONSE = "loanDelinquencyActionResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/ExternalAssetOwnerLoanProductAttributes.feature b/fineract-e2e-tests-runner/src/test/resources/features/ExternalAssetOwnerLoanProductAttributes.feature new file mode 100644 index 00000000000..9d2532cdc9d --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/ExternalAssetOwnerLoanProductAttributes.feature @@ -0,0 +1,121 @@ +@AssetExternalizationFeature +Feature: External Asset Owner Loan Product Attributes + + Scenario: Verify external asset owner loan product attributes template contains buy down fee amortization strategy + Then External asset owner loan product attributes template contains the following attributes: + | attributeKey | attributeValues | multiValue | + | SETTLEMENT_MODEL | DEFAULT_SETTLEMENT,DELAYED_SETTLEMENT | false | + | BUY_DOWN_FEE_AMORTIZATION_STRATEGY | DEFERRED,IMMEDIATE | false | + + Scenario: Verify buy down fee amortization strategy attribute can be created and retrieved + When Admin creates a new loan product for external asset owner loan product attributes + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product does not exist + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "IMMEDIATE" for the new loan product + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "IMMEDIATE" + Then External asset owner loan product attribute "EXCLUDED_TRANSACTION_TYPES" of the new loan product does not exist + + Scenario: Verify buy down fee amortization strategy attribute can be switched between DEFERRED and IMMEDIATE + When Admin creates a new loan product for external asset owner loan product attributes + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "DEFERRED" for the new loan product + When Admin updates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product to value "IMMEDIATE" + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "IMMEDIATE" + When Admin updates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product to value "DEFERRED" + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "DEFERRED" + + Scenario: Verify buy down fee amortization strategy attribute value is accepted in any letter case and stored canonical + When Admin creates a new loan product for external asset owner loan product attributes + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "immediate" for the new loan product + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "IMMEDIATE" + When Admin updates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product to value "Deferred" + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "DEFERRED" + + Scenario: Verify duplicate buy down fee amortization strategy attribute is rejected + When Admin creates a new loan product for external asset owner loan product attributes + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "DEFERRED" for the new loan product + Then Creating external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "IMMEDIATE" for the new loan product results a 403 error and "LOAN_PRODUCT_ATTRIBUTE_ALREADY_EXISTS" error message + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "DEFERRED" + + Scenario Outline: Verify buy down fee amortization strategy attribute creation with invalid value "" is rejected + When Admin creates a new loan product for external asset owner loan product attributes + Then Creating external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "" for the new loan product results a 403 error and "LOAN_PRODUCT_ATTRIBUTE_INVALID" error message + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product does not exist + + Examples: + | attributeValue | + | UNKNOWN | + | DEFERRED,IMMEDIATE | + | DEFAULT_SETTLEMENT | + + Scenario Outline: Verify buy down fee amortization strategy attribute update with invalid value "" is rejected + When Admin creates a new loan product for external asset owner loan product attributes + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "DEFERRED" for the new loan product + Then Updating external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product to value "" results a 403 error and "LOAN_PRODUCT_ATTRIBUTE_INVALID" error message + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "DEFERRED" + + Examples: + | attributeValue | + | UNKNOWN | + | DEFERRED,IMMEDIATE | + + Scenario: Verify buy down fee amortization strategy attribute for non-existing loan product is rejected + Then Creating external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "IMMEDIATE" for non-existing loan product results a 404 error and "LOAN_PRODUCT_NOT_FOUND" error message + + Scenario: Verify buy down fee amortization strategy and excluded transaction types attributes are managed independently on the same loan product + When Admin creates a new loan product for external asset owner loan product attributes + When Admin creates external asset owner loan product attribute "EXCLUDED_TRANSACTION_TYPES" with value "BUY_DOWN_FEE" for the new loan product + When Admin updates external asset owner loan product attribute "EXCLUDED_TRANSACTION_TYPES" of the new loan product to value "BUY_DOWN_FEE,BUY_DOWN_FEE_AMORTIZATION" + Then External asset owner loan product attribute "EXCLUDED_TRANSACTION_TYPES" of the new loan product has value "BUY_DOWN_FEE,BUY_DOWN_FEE_AMORTIZATION" + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product does not exist + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "DEFERRED" for the new loan product + When Admin updates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product to value "IMMEDIATE" + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "IMMEDIATE" + Then External asset owner loan product attribute "EXCLUDED_TRANSACTION_TYPES" of the new loan product has value "BUY_DOWN_FEE,BUY_DOWN_FEE_AMORTIZATION" + + Scenario: Verify buy down fee amortization strategy attribute creation and update are guarded by their permissions + When Admin creates a new loan product for external asset owner loan product attributes + When Admin creates new user with "EAO_ATTR_NO_PERMISSION" username, "EAO_ATTR_NO_PERMISSION_ROLE" role name and given permissions: + | READ_LOAN | + Then Created user creating external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "IMMEDIATE" for the new loan product results a 403 error and "USER_HAS_NO_CREATE_ATTRIBUTE_AUTHORITY" error message + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product does not exist + When Admin creates external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" with value "DEFERRED" for the new loan product + Then Created user updating external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product to value "IMMEDIATE" results a 403 error and "USER_HAS_NO_UPDATE_ATTRIBUTE_AUTHORITY" error message + Then External asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" of the new loan product has value "DEFERRED" + + Scenario: Verify buy down fee is amortized over the loan term as without the attribute when buy down fee amortization strategy is DEFERRED + When Admin set external asset owner loan product attribute "BUY_DOWN_FEE_AMORTIZATION_STRATEGY" value "DEFERRED" for loan product "LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_BUYDOWN_FEES" + When Admin sets the business date to "01 January 2024" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_BUYDOWN_FEES | 01 January 2024 | 100 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "100" amount and expected disbursement date on "01 January 2024" + And Admin successfully disburse the loan on "01 January 2024" with "100" EUR transaction amount + When Admin adds buy down fee with "AUTOPAY" payment type to the loan on "01 January 2024" with "50" EUR transaction amount + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | + | 01 January 2024 | Buy Down Fee | 50.0 | 0.0 | 50.0 | 0.0 | 0.0 | 0.0 | false | + And Buy down fee contains the following data: + | Date | Fee Amount | Amortized Amount | Not Yet Amortized Amount | Adjusted Amount | Charged Off Amount | + | 01 January 2024 | 50.0 | 0.0 | 50.0 | 0.0 | 0.0 | + When Admin sets the business date to "1 February 2024" + And Customer makes "AUTOPAY" repayment on "01 February 2024" with 33.72 EUR transaction amount + When Admin sets the business date to "1 March 2024" + And Customer makes "AUTOPAY" repayment on "01 March 2024" with 33.72 EUR transaction amount + When Admin sets the business date to "1 April 2024" + When Admin runs inline COB job for Loan + And Customer makes "AUTOPAY" repayment on "01 April 2024" with 33.73 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + And Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | + | 01 January 2024 | Buy Down Fee | 50.0 | 0.0 | 50.0 | 0.0 | 0.0 | 0.0 | false | + | 01 February 2024 | Repayment | 33.72 | 33.14 | 0.58 | 0.0 | 0.0 | 66.86 | false | + | 01 March 2024 | Repayment | 33.72 | 33.33 | 0.39 | 0.0 | 0.0 | 33.53 | false | + | 31 March 2024 | Accrual | 1.16 | 0.0 | 1.16 | 0.0 | 0.0 | 0.0 | false | + | 31 March 2024 | Buy Down Fee Amortization | 50.0 | 0.0 | 50.0 | 0.0 | 0.0 | 0.0 | false | + | 01 April 2024 | Repayment | 33.73 | 33.53 | 0.2 | 0.0 | 0.0 | 0.0 | false | + | 01 April 2024 | Accrual | 0.01 | 0.0 | 0.01 | 0.0 | 0.0 | 0.0 | false | + And Buy down fee by external-id contains the following data: + | Date | Fee Amount | Amortized Amount | Not Yet Amortized Amount | Adjusted Amount | Charged Off Amount | + | 01 January 2024 | 50.0 | 50.0 | 0.0 | 0.0 | 0.0 | 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/BuyDownFeeAmortizationStrategyExternalAssetOwnerLoanProductAttribute.java b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/BuyDownFeeAmortizationStrategyExternalAssetOwnerLoanProductAttribute.java new file mode 100644 index 00000000000..57bc14e0b6f --- /dev/null +++ b/fineract-investor/src/main/java/org/apache/fineract/investor/data/attribute/BuyDownFeeAmortizationStrategyExternalAssetOwnerLoanProductAttribute.java @@ -0,0 +1,65 @@ +/** + * 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 BuyDownFeeAmortizationStrategyExternalAssetOwnerLoanProductAttribute implements ExternalAssetOwnerLoanProductAttribute { + + DEFERRED, // + IMMEDIATE; // + + private final String attributeKey; + + BuyDownFeeAmortizationStrategyExternalAssetOwnerLoanProductAttribute() { + this.attributeKey = "BUY_DOWN_FEE_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(final String attributeValue) { + return getAttributeValue().equals(attributeValue.toUpperCase(Locale.ROOT)); + } + + @Override + public boolean isMultiValue() { + return false; + } + + @Override + public String normalize(final String attributeValue) { + return attributeValue.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..d575fbbdbcf 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,15 +55,15 @@ @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 + @CacheEvict(cacheNames = "externalAssetOwnerLoanProductAttributes", allEntries = true) public CommandProcessingResult createExternalAssetOwnerLoanProductAttribute(JsonCommand command) { final JsonElement json = fromApiJsonHelper.parse(command.json()); String attributeKey = fromApiJsonHelper.extractStringNamed(ExternalAssetOwnerLoanProductAttributeRequestParameters.ATTRIBUTE_KEY, @@ -75,10 +72,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 +87,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 +124,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 +150,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 +164,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..10349cdb188 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(3, 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(); + } +}