You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fineract.apache.org by ad...@apache.org on 2025/10/17 12:25:45 UTC
(fineract) branch develop updated: FINERACT-2380: create feign client
This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 553347b320 FINERACT-2380: create feign client
553347b320 is described below
commit 553347b32057325faee69efba6ebc3bc114cadf4
Author: Attila Budai <bu...@gmail.com>
AuthorDate: Fri Oct 3 08:12:14 2025 +0200
FINERACT-2380: create feign client
---
build.gradle | 5 +
fineract-client-feign/.openapi-generator-ignore | 32 +
fineract-client-feign/build.gradle | 194 ++++
fineract-client-feign/dependencies.gradle | 49 +
.../fineract/client/adapter/ExternalIdAdapter.java | 84 ++
.../client/feign/BasicAuthRequestInterceptor.java | 56 ++
.../fineract/client/feign/FeignException.java | 115 +++
.../client/feign/FineractErrorDecoder.java | 107 +++
.../fineract/client/feign/FineractFeignClient.java | 1002 ++++++++++++++++++++
.../client/feign/FineractFeignClientConfig.java | 293 ++++++
.../client/feign/FineractMultipartEncoder.java | 198 ++++
.../fineract/client/feign/ObjectMapperFactory.java | 73 ++
.../client/feign/TenantIdRequestInterceptor.java | 42 +
.../client/feign/services/DocumentsApiFixed.java | 140 +++
.../fineract/client/feign/services/ImagesApi.java | 97 ++
.../client/feign/services/RunReportsApi.java | 66 ++
.../feign/util/CallFailedRuntimeException.java | 91 ++
.../fineract/client/feign/util/FeignCalls.java | 80 ++
.../apache/fineract/client/util/FeignParts.java | 136 +++
.../src/main/resources/templates/java/api.mustache | 47 +
.../client/adapter/ExternalIdAdapterTest.java | 192 ++++
.../fineract/client/feign/FeignExceptionTest.java | 140 +++
.../client/feign/FineractErrorDecoderTest.java | 180 ++++
.../feign/FineractFeignClientConfigTest.java | 93 ++
.../client/feign/ObjectMapperFactoryTest.java | 120 +++
.../integration/EncoderDecoderIntegrationTest.java | 421 ++++++++
.../FineractFeignClientIntegrationTest.java | 252 +++++
.../performance/ConnectionPoolPerformanceTest.java | 269 ++++++
.../services/DocumentsApiFixedIntegrationTest.java | 208 ++++
.../client/services/ImagesApiIntegrationTest.java | 180 ++++
.../services/RunReportsApiIntegrationTest.java | 166 ++++
.../fineract/client/util/FeignPartsTest.java | 296 ++++++
.../domain/BasicPasswordEncodablePlatformUser.java | 12 +-
integration-tests/build.gradle | 62 +-
integration-tests/dependencies.gradle | 13 +-
.../client/FeignClientSmokeTest.java | 65 ++
.../integrationtests/client/FeignDocumentTest.java | 150 +++
.../integrationtests/client/FeignImageTest.java | 111 +++
.../client/FeignIntegrationTest.java | 103 ++
.../client/feign/FeignLoanTestBase.java | 219 +++++
.../client/feign/helpers/FeignAccountHelper.java | 95 ++
.../feign/helpers/FeignBusinessDateHelper.java | 62 ++
.../client/feign/helpers/FeignClientHelper.java | 65 ++
.../feign/helpers/FeignJournalEntryHelper.java | 69 ++
.../client/feign/helpers/FeignLoanHelper.java | 109 +++
.../feign/helpers/FeignTransactionHelper.java | 75 ++
.../client/feign/modules/LoanProductTemplates.java | 240 +++++
.../client/feign/modules/LoanRequestBuilders.java | 135 +++
.../client/feign/modules/LoanTestAccounts.java | 204 ++++
.../client/feign/modules/LoanTestData.java | 231 +++++
.../client/feign/modules/LoanTestValidators.java | 126 +++
.../client/feign/tests/FeignLoanCreationTest.java | 159 ++++
.../feign/tests/FeignLoanTestBaseSmokeTest.java | 45 +
.../integrationtests/cob/CobPartitioningTest.java | 98 +-
.../common/FineractFeignClientHelper.java | 58 ++
settings.gradle | 1 +
56 files changed, 7908 insertions(+), 23 deletions(-)
diff --git a/build.gradle b/build.gradle
index 0d7d4af5d3..1d25ed0514 100644
--- a/build.gradle
+++ b/build.gradle
@@ -44,6 +44,7 @@ buildscript {
'twofactor-tests',
'oauth2-tests',
'fineract-client',
+ 'fineract-client-feign',
'fineract-avro-schemas',
'fineract-e2e-tests-core',
'fineract-e2e-tests-runner',
@@ -55,6 +56,7 @@ buildscript {
[
'fineract-avro-schemas',
'fineract-client',
+ 'fineract-client-feign',
'fineract-core',
'fineract-cob',
'fineract-validation',
@@ -558,6 +560,9 @@ configure(project.fineractJavaProjects) {
if (project.path == ':fineract-client') {
excludedPaths = '.*/build/generated/java/src/main/java/.*'
}
+ if (project.path == ':fineract-client-feign') {
+ excludedPaths = '.*/build/generated/java/src/main/java/.*'
+ }
disable(
// TODO Remove disabled checks from this list, by fixing remaining usages
"UnusedVariable",
diff --git a/fineract-client-feign/.openapi-generator-ignore b/fineract-client-feign/.openapi-generator-ignore
new file mode 100644
index 0000000000..67b0c43e13
--- /dev/null
+++ b/fineract-client-feign/.openapi-generator-ignore
@@ -0,0 +1,32 @@
+**/README.md
+**/pom.xml
+**/build.sbt
+**/*.gradle
+**/.gitignore
+**/git_push.sh
+**/api/*
+**/gradle*
+**/gradle
+**/src/main/AndroidManifest.xml
+
+# https://issues.apache.org/jira/browse/FINERACT-1231
+**/feign/*.java
+!**/feign/CollectionFormats.java
+!**/feign/StringUtil.java
+
+# Manual API overrides - do not regenerate
+# https://issues.apache.org/jira/browse/FINERACT-1263
+**/services/RunReportsApi.java
+**/services/ImagesApi.java
+**/services/DocumentsApiFixed.java
+
+# Utility classes - do not regenerate
+**/util/*.java
+**/adapter/*.java
+
+# Feign configuration - do not regenerate
+**/feign/FineractFeignClient.java
+**/feign/FineractFeignClientConfig.java
+**/feign/BasicAuthRequestInterceptor.java
+**/feign/ObjectMapperFactory.java
+**/feign/FeignException.java
diff --git a/fineract-client-feign/build.gradle b/fineract-client-feign/build.gradle
new file mode 100644
index 0000000000..c158b75aa6
--- /dev/null
+++ b/fineract-client-feign/build.gradle
@@ -0,0 +1,194 @@
+/**
+ * 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.
+ */
+apply plugin: 'org.openapi.generator'
+apply plugin: 'jacoco'
+description = 'Fineract Client with Feign'
+
+apply from: 'dependencies.gradle'
+
+openApiMeta {
+ generatorName = 'Fineract-Feign'
+ packageName = 'org.apache.fineract.client.feign'
+ outputFolder = "$buildDir/meta".toString()
+}
+
+openApiValidate {
+ inputSpec = "file:///$swaggerFile"
+ recommend = true
+}
+
+tasks.register('buildJavaSdk', org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
+ generatorName = 'java'
+ library = 'feign'
+ verbose = false
+ validateSpec = false
+ skipValidateSpec = true
+ inputSpec = "file:///$swaggerFile"
+ outputDir = "$buildDir/generated/temp-java".toString()
+ templateDir = "$projectDir/src/main/resources/templates/java"
+ groupId = 'org.apache.fineract'
+ apiPackage = 'org.apache.fineract.client.feign.services'
+ invokerPackage = 'org.apache.fineract.client.feign'
+ modelPackage = 'org.apache.fineract.client.models'
+ generateModelTests = false
+ generateApiTests = false
+ ignoreFileOverride = "$projectDir/.openapi-generator-ignore"
+ configOptions = [
+ dateLibrary : 'java8',
+ library : 'feign',
+ feignVersion : '13.6',
+ feignApacheHttpClient : 'true',
+ useFeign13 : 'true',
+ useFeignApacheHttpClient : 'true',
+ hideGenerationTimestamp : 'true',
+ containerDefaultToNull : 'true',
+ oauth2Implementation : 'none',
+ useJakartaEe : 'true'
+
+ ]
+ dependsOn(':fineract-provider:resolve')
+}
+
+sourceSets {
+ main {
+ java {
+ srcDirs = [
+ new File(buildDir, "generated/java/src/main/java"),
+ "$projectDir/src/main/java"
+ ]
+ destinationDirectory = layout.buildDirectory.dir('classes/java/main').get().asFile
+ }
+ output.resourcesDir = layout.buildDirectory.dir('resources/main').get().asFile
+ }
+ test {
+ java {
+ destinationDirectory = layout.buildDirectory.dir('classes/java/test').get().asFile
+ }
+ output.resourcesDir = layout.buildDirectory.dir('resources/test').get().asFile
+ }
+}
+
+tasks.withType(Jar).configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+task cleanupGeneratedJavaFiles() {
+ def tempDir = file("$buildDir/generated/temp-java")
+ def targetDir = file("$buildDir/generated/java")
+
+ inputs.dir(tempDir)
+ outputs.dir(targetDir)
+
+ doLast {
+ copy {
+ from tempDir
+ into targetDir
+ filter { line ->
+ line
+ .replaceAll(", \\)", ")")
+ .replaceAll(", , @HeaderMap", ", @HeaderMap")
+ .replaceAll("\\(, ", "(")
+ }
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ }
+ }
+ dependsOn("buildJavaSdk")
+}
+
+tasks.named('compileJava') {
+ outputs.cacheIf { true }
+ dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
+ mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
+}
+
+tasks.named('sourcesJar') {
+ dependsOn(cleanupGeneratedJavaFiles)
+ mustRunAfter(cleanupGeneratedJavaFiles)
+
+ from(sourceSets.main.java.srcDirs) {
+ include "**/*.java"
+ }
+}
+
+tasks.named('licenseFormatMain') {
+ dependsOn(cleanupGeneratedJavaFiles)
+ mustRunAfter(cleanupGeneratedJavaFiles)
+ source = sourceSets.main.java.srcDirs
+}
+
+tasks.named('licenseMain') {
+ dependsOn(licenseFormatMain)
+ mustRunAfter(licenseFormatMain)
+}
+
+tasks.withType(JavaCompile).configureEach {
+ options.encoding = 'UTF-8'
+ options.compilerArgs << '-parameters'
+ options.errorprone {
+ excludedPaths = '.*/build/generated/java/src/main/java/.*'
+ }
+}
+
+test {
+ useJUnitPlatform()
+}
+
+configurations {
+ generatedCompileClasspath.extendsFrom implementation
+ generatedRuntimeClasspath.extendsFrom runtimeClasspath
+}
+
+javadoc {
+ options.encoding = 'UTF-8'
+}
+
+spotbugsMain {
+ enabled = false
+}
+
+spotbugsTest {
+ enabled = false
+}
+
+jacoco {
+ toolVersion = "0.8.11"
+}
+
+jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ html.required = true
+ csv.required = false
+ }
+ afterEvaluate {
+ classDirectories.setFrom(files(classDirectories.files.collect {
+ fileTree(dir: it, exclude: [
+ '**/build/generated/**',
+ '**/org/apache/fineract/client/models/**',
+ '**/org/apache/fineract/client/services/**Api.class',
+ '**/org/apache/fineract/client/auth/**'
+ ])
+ }))
+ }
+}
+
+test {
+ finalizedBy jacocoTestReport
+}
diff --git a/fineract-client-feign/dependencies.gradle b/fineract-client-feign/dependencies.gradle
new file mode 100644
index 0000000000..59cb8278b0
--- /dev/null
+++ b/fineract-client-feign/dependencies.gradle
@@ -0,0 +1,49 @@
+/**
+ * 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.
+ */
+dependencies {
+ // Feign dependencies
+ implementation(
+ 'io.github.openfeign:feign-core:13.6',
+ 'io.github.openfeign:feign-jackson:13.6',
+ 'io.github.openfeign:feign-slf4j:13.6',
+ 'io.github.openfeign:feign-hc5:13.6',
+ 'io.github.openfeign:feign-okhttp:13.6',
+ 'io.github.openfeign.form:feign-form:3.8.0',
+ 'org.apache.httpcomponents.client5:httpclient5:5.2.1',
+ 'com.squareup.okhttp3:okhttp:4.12.0',
+ 'com.fasterxml.jackson.core:jackson-databind',
+ 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310',
+ 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8',
+ 'jakarta.annotation:jakarta.annotation-api:3.0.0',
+ 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.15',
+ 'org.apache.commons:commons-lang3:3.12.0',
+ 'org.slf4j:slf4j-api:1.7.36',
+ 'org.projectlombok:lombok'
+ )
+
+ // Test dependencies
+ testImplementation(
+ 'org.junit.jupiter:junit-jupiter-api:5.11.3',
+ 'org.junit.jupiter:junit-jupiter-engine:5.11.3',
+ 'org.mockito:mockito-core:5.14.2',
+ 'org.assertj:assertj-core:3.26.3',
+ 'org.slf4j:slf4j-simple:1.7.36',
+ 'org.wiremock:wiremock-standalone'
+ )
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/adapter/ExternalIdAdapter.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/adapter/ExternalIdAdapter.java
new file mode 100644
index 0000000000..00bd2f0ee0
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/adapter/ExternalIdAdapter.java
@@ -0,0 +1,84 @@
+/**
+ * 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.client.adapter;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+import org.apache.fineract.client.models.ExternalId;
+
+/**
+ * Custom Jackson adapter for ExternalId type serialization and deserialization. This adapter ensures that ExternalId
+ * objects are properly serialized to their string value and deserialized from string values.
+ */
+public final class ExternalIdAdapter {
+
+ private ExternalIdAdapter() {}
+
+ /**
+ * Jackson Serializer for ExternalId. Serializes an ExternalId object to its string value, or null if the ExternalId
+ * or its value is null.
+ */
+ public static class Serializer extends JsonSerializer<ExternalId> {
+
+ @Override
+ public void serialize(ExternalId value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ if (value == null || value.getValue() == null) {
+ gen.writeNull();
+ } else {
+ gen.writeString(value.getValue());
+ }
+ }
+ }
+
+ /**
+ * Jackson Deserializer for ExternalId. Deserializes a string value to an ExternalId object, or null if the input is
+ * null.
+ */
+ public static class Deserializer extends JsonDeserializer<ExternalId> {
+
+ @Override
+ public ExternalId deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+ String value = p.getValueAsString();
+ if (value == null) {
+ return null;
+ }
+ ExternalId externalId = new ExternalId();
+ externalId.setValue(value);
+ return externalId;
+ }
+ }
+
+ /**
+ * Creates a Jackson SimpleModule configured with the ExternalId serializer and deserializer.
+ *
+ * @return A configured SimpleModule ready to be registered with an ObjectMapper
+ */
+ public static SimpleModule createModule() {
+ SimpleModule module = new SimpleModule("ExternalIdModule");
+ module.addSerializer(ExternalId.class, new Serializer());
+ module.addDeserializer(ExternalId.class, new Deserializer());
+ return module;
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/BasicAuthRequestInterceptor.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/BasicAuthRequestInterceptor.java
new file mode 100644
index 0000000000..dad772522d
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/BasicAuthRequestInterceptor.java
@@ -0,0 +1,56 @@
+/**
+ * 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.client.feign;
+
+import feign.RequestInterceptor;
+import feign.RequestTemplate;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * Request interceptor that adds Basic Authentication header to requests.
+ */
+public class BasicAuthRequestInterceptor implements RequestInterceptor {
+
+ private static final String AUTHORIZATION_HEADER = "Authorization";
+ private static final String BASIC_AUTH_PREFIX = "Basic ";
+
+ private final String credentials;
+
+ /**
+ * Creates a new BasicAuthRequestInterceptor with the specified credentials.
+ *
+ * @param username
+ * the username for authentication
+ * @param password
+ * the password for authentication
+ */
+ public BasicAuthRequestInterceptor(String username, String password) {
+ if (username == null || password == null) {
+ throw new IllegalArgumentException("Username and password cannot be null");
+ }
+ String auth = username + ":" + password;
+ this.credentials = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public void apply(RequestTemplate template) {
+ template.header(AUTHORIZATION_HEADER, BASIC_AUTH_PREFIX + credentials);
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FeignException.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FeignException.java
new file mode 100644
index 0000000000..a80430c00b
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FeignException.java
@@ -0,0 +1,115 @@
+/**
+ * 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.client.feign;
+
+import feign.Request;
+import java.nio.charset.Charset;
+
+/**
+ * Base exception class for Feign client exceptions.
+ */
+public class FeignException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int status;
+ private final Request request;
+ private final byte[] responseBody;
+ private final String developerMessage;
+ private final String userMessage;
+
+ protected FeignException(int status, String message, Request request) {
+ this(status, message, request, (byte[]) null);
+ }
+
+ protected FeignException(int status, String message, Request request, Throwable cause) {
+ this(status, message, request, null, cause);
+ }
+
+ protected FeignException(int status, String message, Request request, byte[] responseBody) {
+ super(message);
+ this.status = status;
+ this.request = request;
+ this.responseBody = responseBody;
+ this.developerMessage = null;
+ this.userMessage = null;
+ }
+
+ protected FeignException(int status, String message, Request request, byte[] responseBody, Throwable cause) {
+ super(message, cause);
+ this.status = status;
+ this.request = request;
+ this.responseBody = responseBody;
+ this.developerMessage = null;
+ this.userMessage = null;
+ }
+
+ public FeignException(int status, String message, Request request, byte[] responseBody, String developerMessage, String userMessage) {
+ super(message);
+ this.status = status;
+ this.request = request;
+ this.responseBody = responseBody;
+ this.developerMessage = developerMessage;
+ this.userMessage = userMessage;
+ }
+
+ public int status() {
+ return status;
+ }
+
+ public Request request() {
+ return request;
+ }
+
+ public byte[] responseBody() {
+ return responseBody;
+ }
+
+ public String responseBodyAsString() {
+ return responseBody != null ? new String(responseBody, Charset.defaultCharset()) : null;
+ }
+
+ public String getDeveloperMessage() {
+ return developerMessage;
+ }
+
+ public String getUserMessage() {
+ return userMessage;
+ }
+
+ @Override
+ public String getMessage() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("status ").append(status);
+
+ if (userMessage != null) {
+ sb.append(": ").append(userMessage);
+ }
+
+ if (developerMessage != null) {
+ sb.append(" (").append(developerMessage).append(")");
+ }
+
+ if (super.getMessage() != null && userMessage == null && developerMessage == null) {
+ sb.append(": ").append(super.getMessage());
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractErrorDecoder.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractErrorDecoder.java
new file mode 100644
index 0000000000..6c5525983f
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractErrorDecoder.java
@@ -0,0 +1,107 @@
+/**
+ * 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.client.feign;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import feign.Response;
+import feign.codec.ErrorDecoder;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class FineractErrorDecoder implements ErrorDecoder {
+
+ private final ErrorDecoder defaultDecoder = new Default();
+ private final ObjectMapper objectMapper = ObjectMapperFactory.getShared();
+
+ @Override
+ public Exception decode(String methodKey, Response response) {
+ try {
+ if (response.body() != null) {
+ byte[] bodyData = readResponseBody(response);
+
+ try {
+ JsonNode rootNode = objectMapper.readTree(bodyData);
+
+ String developerMessage = extractField(rootNode, "developerMessage");
+ String userMessage = extractField(rootNode, "userMessage");
+ String validationErrors = extractValidationErrors(rootNode);
+
+ if (developerMessage != null || userMessage != null || validationErrors != null) {
+ String enhancedDeveloperMessage = developerMessage;
+ if (validationErrors != null) {
+ enhancedDeveloperMessage = validationErrors;
+ }
+ return new FeignException(response.status(), userMessage != null ? userMessage : enhancedDeveloperMessage,
+ response.request(), bodyData, enhancedDeveloperMessage, userMessage);
+ }
+ } catch (IOException e) {
+ return defaultDecoder.decode(methodKey, response);
+ }
+ }
+ } catch (IOException e) {
+ return defaultDecoder.decode(methodKey, response);
+ }
+
+ return defaultDecoder.decode(methodKey, response);
+ }
+
+ private byte[] readResponseBody(Response response) throws IOException {
+ if (response.body() == null) {
+ return new byte[0];
+ }
+
+ try (InputStream inputStream = response.body().asInputStream()) {
+ return inputStream.readAllBytes();
+ }
+ }
+
+ private String extractField(JsonNode node, String fieldName) {
+ JsonNode fieldNode = node.get(fieldName);
+ return fieldNode != null && !fieldNode.isNull() ? fieldNode.asText() : null;
+ }
+
+ private String extractValidationErrors(JsonNode rootNode) {
+ JsonNode errorsNode = rootNode.get("errors");
+ if (errorsNode != null && errorsNode.isArray() && errorsNode.size() > 0) {
+ StringBuilder errors = new StringBuilder("Validation errors: ");
+ for (JsonNode error : errorsNode) {
+ String parameterName = extractField(error, "parameterName");
+ String defaultUserMessage = extractField(error, "defaultUserMessage");
+ String developerMessage = extractField(error, "developerMessage");
+
+ if (errors.length() > "Validation errors: ".length()) {
+ errors.append("; ");
+ }
+
+ if (parameterName != null) {
+ errors.append("[").append(parameterName).append("] ");
+ }
+
+ if (defaultUserMessage != null) {
+ errors.append(defaultUserMessage);
+ } else if (developerMessage != null) {
+ errors.append(developerMessage);
+ }
+ }
+ return errors.length() > "Validation errors: ".length() ? errors.toString() : null;
+ }
+ return null;
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java
new file mode 100644
index 0000000000..8c049039f8
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java
@@ -0,0 +1,1002 @@
+/**
+ * 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.client.feign;
+
+import org.apache.fineract.client.feign.services.AccountNumberFormatApi;
+import org.apache.fineract.client.feign.services.AccountTransfersApi;
+import org.apache.fineract.client.feign.services.AccountingClosureApi;
+import org.apache.fineract.client.feign.services.AccountingRulesApi;
+import org.apache.fineract.client.feign.services.AdhocQueryApiApi;
+import org.apache.fineract.client.feign.services.AuditsApi;
+import org.apache.fineract.client.feign.services.AuthenticationHttpBasicApi;
+import org.apache.fineract.client.feign.services.BatchApiApi;
+import org.apache.fineract.client.feign.services.BulkImportApi;
+import org.apache.fineract.client.feign.services.BulkLoansApi;
+import org.apache.fineract.client.feign.services.BusinessDateManagementApi;
+import org.apache.fineract.client.feign.services.BusinessStepConfigurationApi;
+import org.apache.fineract.client.feign.services.CacheApi;
+import org.apache.fineract.client.feign.services.CalendarApi;
+import org.apache.fineract.client.feign.services.CashierJournalsApi;
+import org.apache.fineract.client.feign.services.CashiersApi;
+import org.apache.fineract.client.feign.services.CentersApi;
+import org.apache.fineract.client.feign.services.ChargesApi;
+import org.apache.fineract.client.feign.services.ClientApi;
+import org.apache.fineract.client.feign.services.ClientChargesApi;
+import org.apache.fineract.client.feign.services.ClientCollateralManagementApi;
+import org.apache.fineract.client.feign.services.ClientFamilyMemberApi;
+import org.apache.fineract.client.feign.services.ClientIdentifierApi;
+import org.apache.fineract.client.feign.services.ClientSearchV2Api;
+import org.apache.fineract.client.feign.services.ClientTransactionApi;
+import org.apache.fineract.client.feign.services.ClientsAddressApi;
+import org.apache.fineract.client.feign.services.CodeValuesApi;
+import org.apache.fineract.client.feign.services.CodesApi;
+import org.apache.fineract.client.feign.services.CollateralManagementApi;
+import org.apache.fineract.client.feign.services.CollectionSheetApi;
+import org.apache.fineract.client.feign.services.CreditBureauConfigurationApi;
+import org.apache.fineract.client.feign.services.CurrencyApi;
+import org.apache.fineract.client.feign.services.DataTablesApi;
+import org.apache.fineract.client.feign.services.DefaultApi;
+import org.apache.fineract.client.feign.services.DelinquencyRangeAndBucketsManagementApi;
+import org.apache.fineract.client.feign.services.DepositAccountOnHoldFundTransactionsApi;
+import org.apache.fineract.client.feign.services.DeviceRegistrationApi;
+import org.apache.fineract.client.feign.services.DocumentsApi;
+import org.apache.fineract.client.feign.services.DocumentsApiFixed;
+import org.apache.fineract.client.feign.services.EntityDataTableApi;
+import org.apache.fineract.client.feign.services.EntityFieldConfigurationApi;
+import org.apache.fineract.client.feign.services.ExternalAssetOwnerLoanProductAttributesApi;
+import org.apache.fineract.client.feign.services.ExternalAssetOwnersApi;
+import org.apache.fineract.client.feign.services.ExternalEventConfigurationApi;
+import org.apache.fineract.client.feign.services.ExternalServicesApi;
+import org.apache.fineract.client.feign.services.FetchAuthenticatedUserDetailsApi;
+import org.apache.fineract.client.feign.services.FineractEntityApi;
+import org.apache.fineract.client.feign.services.FixedDepositAccountApi;
+import org.apache.fineract.client.feign.services.FixedDepositAccountTransactionsApi;
+import org.apache.fineract.client.feign.services.FixedDepositProductApi;
+import org.apache.fineract.client.feign.services.FloatingRatesApi;
+import org.apache.fineract.client.feign.services.FundsApi;
+import org.apache.fineract.client.feign.services.GeneralLedgerAccountApi;
+import org.apache.fineract.client.feign.services.GlobalConfigurationApi;
+import org.apache.fineract.client.feign.services.GroupsApi;
+import org.apache.fineract.client.feign.services.GroupsLevelApi;
+import org.apache.fineract.client.feign.services.GuarantorsApi;
+import org.apache.fineract.client.feign.services.HolidaysApi;
+import org.apache.fineract.client.feign.services.HooksApi;
+import org.apache.fineract.client.feign.services.ImagesApi;
+import org.apache.fineract.client.feign.services.InlineJobApi;
+import org.apache.fineract.client.feign.services.InstanceModeApi;
+import org.apache.fineract.client.feign.services.InterOperationApi;
+import org.apache.fineract.client.feign.services.InterestRateChartApi;
+import org.apache.fineract.client.feign.services.InterestRateSlabAKAInterestBandsApi;
+import org.apache.fineract.client.feign.services.InternalCobApi;
+import org.apache.fineract.client.feign.services.JournalEntriesApi;
+import org.apache.fineract.client.feign.services.LikelihoodApi;
+import org.apache.fineract.client.feign.services.ListReportMailingJobHistoryApi;
+import org.apache.fineract.client.feign.services.LoanAccountLockApi;
+import org.apache.fineract.client.feign.services.LoanBuyDownFeesApi;
+import org.apache.fineract.client.feign.services.LoanCapitalizedIncomeApi;
+import org.apache.fineract.client.feign.services.LoanChargesApi;
+import org.apache.fineract.client.feign.services.LoanCobCatchUpApi;
+import org.apache.fineract.client.feign.services.LoanCollateralApi;
+import org.apache.fineract.client.feign.services.LoanCollateralManagementApi;
+import org.apache.fineract.client.feign.services.LoanDisbursementDetailsApi;
+import org.apache.fineract.client.feign.services.LoanInterestPauseApi;
+import org.apache.fineract.client.feign.services.LoanProductsApi;
+import org.apache.fineract.client.feign.services.LoanReschedulingApi;
+import org.apache.fineract.client.feign.services.LoanTransactionsApi;
+import org.apache.fineract.client.feign.services.LoansApi;
+import org.apache.fineract.client.feign.services.LoansPointInTimeApi;
+import org.apache.fineract.client.feign.services.MakerCheckerOr4EyeFunctionalityApi;
+import org.apache.fineract.client.feign.services.MappingFinancialActivitiesToAccountsApi;
+import org.apache.fineract.client.feign.services.MeetingsApi;
+import org.apache.fineract.client.feign.services.MixMappingApi;
+import org.apache.fineract.client.feign.services.MixReportApi;
+import org.apache.fineract.client.feign.services.MixTaxonomyApi;
+import org.apache.fineract.client.feign.services.NotesApi;
+import org.apache.fineract.client.feign.services.NotificationApi;
+import org.apache.fineract.client.feign.services.OfficesApi;
+import org.apache.fineract.client.feign.services.PasswordPreferencesApi;
+import org.apache.fineract.client.feign.services.PaymentTypeApi;
+import org.apache.fineract.client.feign.services.PeriodicAccrualAccountingApi;
+import org.apache.fineract.client.feign.services.PermissionsApi;
+import org.apache.fineract.client.feign.services.PocketApi;
+import org.apache.fineract.client.feign.services.PovertyLineApi;
+import org.apache.fineract.client.feign.services.ProductMixApi;
+import org.apache.fineract.client.feign.services.ProductsApi;
+import org.apache.fineract.client.feign.services.ProgressiveLoanApi;
+import org.apache.fineract.client.feign.services.ProvisioningCategoryApi;
+import org.apache.fineract.client.feign.services.ProvisioningCriteriaApi;
+import org.apache.fineract.client.feign.services.ProvisioningEntriesApi;
+import org.apache.fineract.client.feign.services.RateApi;
+import org.apache.fineract.client.feign.services.RecurringDepositAccountApi;
+import org.apache.fineract.client.feign.services.RecurringDepositAccountTransactionsApi;
+import org.apache.fineract.client.feign.services.RecurringDepositProductApi;
+import org.apache.fineract.client.feign.services.RepaymentWithPostDatedChecksApi;
+import org.apache.fineract.client.feign.services.ReportMailingJobsApi;
+import org.apache.fineract.client.feign.services.ReportsApi;
+import org.apache.fineract.client.feign.services.RescheduleLoansApi;
+import org.apache.fineract.client.feign.services.RolesApi;
+import org.apache.fineract.client.feign.services.RunReportsApi;
+import org.apache.fineract.client.feign.services.SavingsAccountApi;
+import org.apache.fineract.client.feign.services.SavingsAccountTransactionsApi;
+import org.apache.fineract.client.feign.services.SavingsChargesApi;
+import org.apache.fineract.client.feign.services.SavingsProductApi;
+import org.apache.fineract.client.feign.services.SchedulerApi;
+import org.apache.fineract.client.feign.services.SchedulerJobApi;
+import org.apache.fineract.client.feign.services.ScoreCardApi;
+import org.apache.fineract.client.feign.services.SearchApiApi;
+import org.apache.fineract.client.feign.services.SelfAccountTransferApi;
+import org.apache.fineract.client.feign.services.SelfAuthenticationApi;
+import org.apache.fineract.client.feign.services.SelfClientApi;
+import org.apache.fineract.client.feign.services.SelfDividendApi;
+import org.apache.fineract.client.feign.services.SelfLoanProductsApi;
+import org.apache.fineract.client.feign.services.SelfLoansApi;
+import org.apache.fineract.client.feign.services.SelfRunReportApi;
+import org.apache.fineract.client.feign.services.SelfSavingsAccountApi;
+import org.apache.fineract.client.feign.services.SelfSavingsProductsApi;
+import org.apache.fineract.client.feign.services.SelfScoreCardApi;
+import org.apache.fineract.client.feign.services.SelfServiceRegistrationApi;
+import org.apache.fineract.client.feign.services.SelfShareAccountsApi;
+import org.apache.fineract.client.feign.services.SelfShareProductsApi;
+import org.apache.fineract.client.feign.services.SelfSpmApi;
+import org.apache.fineract.client.feign.services.SelfThirdPartyTransferApi;
+import org.apache.fineract.client.feign.services.SelfUserApi;
+import org.apache.fineract.client.feign.services.SelfUserDetailsApi;
+import org.apache.fineract.client.feign.services.ShareAccountApi;
+import org.apache.fineract.client.feign.services.SmsApi;
+import org.apache.fineract.client.feign.services.SpmApiLookUpTableApi;
+import org.apache.fineract.client.feign.services.SpmSurveysApi;
+import org.apache.fineract.client.feign.services.StaffApi;
+import org.apache.fineract.client.feign.services.StandingInstructionsApi;
+import org.apache.fineract.client.feign.services.StandingInstructionsHistoryApi;
+import org.apache.fineract.client.feign.services.SurveyApi;
+import org.apache.fineract.client.feign.services.TaxComponentsApi;
+import org.apache.fineract.client.feign.services.TaxGroupApi;
+import org.apache.fineract.client.feign.services.TellerCashManagementApi;
+import org.apache.fineract.client.feign.services.TwoFactorApi;
+import org.apache.fineract.client.feign.services.UserGeneratedDocumentsApi;
+import org.apache.fineract.client.feign.services.UsersApi;
+import org.apache.fineract.client.feign.services.WorkingDaysApi;
+
+/**
+ * Main entry point for creating Feign-based clients for the Fineract API.
+ * <p>
+ * Example usage:
+ *
+ * <pre>
+ * {@code
+ *
+ * FineractFeignClient client = FineractFeignClient.builder().baseUrl("https://localhost:8443/fineract-provider/api/v1")
+ * .credentials("username", "password").build();
+ *
+ * // Access API clients
+ * ClientApi clientsApi = client.clients();
+ * List<ClientData> clients = clientsApi.retrieveAll();
+ * }
+ * </pre>
+ */
+public final class FineractFeignClient {
+
+ private final FineractFeignClientConfig config;
+
+ private FineractFeignClient(Builder builder) {
+ this.config = builder.configBuilder.build();
+ }
+
+ /**
+ * Creates a new builder for configuring a FineractFeignClient.
+ *
+ * @return A new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Creates a new client for the specified API interface.
+ *
+ * @param <T>
+ * The API interface type
+ * @param apiType
+ * The API interface class
+ * @return A configured Feign client for the specified API
+ */
+ public <T> T create(Class<T> apiType) {
+ return config.createClient(apiType);
+ }
+
+ public AccountNumberFormatApi accountNumberFormat() {
+ return create(AccountNumberFormatApi.class);
+ }
+
+ public AccountTransfersApi accountTransfers() {
+ return create(AccountTransfersApi.class);
+ }
+
+ public AccountingClosureApi accountingClosure() {
+ return create(AccountingClosureApi.class);
+ }
+
+ public AccountingRulesApi accountingRules() {
+ return create(AccountingRulesApi.class);
+ }
+
+ public AdhocQueryApiApi adhocQuery() {
+ return create(AdhocQueryApiApi.class);
+ }
+
+ public AuditsApi audits() {
+ return create(AuditsApi.class);
+ }
+
+ public AuthenticationHttpBasicApi authenticationHttpBasic() {
+ return create(AuthenticationHttpBasicApi.class);
+ }
+
+ public BatchApiApi batch() {
+ return create(BatchApiApi.class);
+ }
+
+ public BulkImportApi bulkImport() {
+ return create(BulkImportApi.class);
+ }
+
+ public BulkLoansApi bulkLoans() {
+ return create(BulkLoansApi.class);
+ }
+
+ public BusinessDateManagementApi businessDateManagement() {
+ return create(BusinessDateManagementApi.class);
+ }
+
+ public BusinessStepConfigurationApi businessStepConfiguration() {
+ return create(BusinessStepConfigurationApi.class);
+ }
+
+ public CacheApi cache() {
+ return create(CacheApi.class);
+ }
+
+ public CalendarApi calendar() {
+ return create(CalendarApi.class);
+ }
+
+ public CashierJournalsApi cashierJournals() {
+ return create(CashierJournalsApi.class);
+ }
+
+ public CashiersApi cashiers() {
+ return create(CashiersApi.class);
+ }
+
+ public CentersApi centers() {
+ return create(CentersApi.class);
+ }
+
+ public ChargesApi charges() {
+ return create(ChargesApi.class);
+ }
+
+ public ClientApi clients() {
+ return create(ClientApi.class);
+ }
+
+ public ClientChargesApi clientCharges() {
+ return create(ClientChargesApi.class);
+ }
+
+ public ClientCollateralManagementApi clientCollateralManagement() {
+ return create(ClientCollateralManagementApi.class);
+ }
+
+ public ClientFamilyMemberApi clientFamilyMember() {
+ return create(ClientFamilyMemberApi.class);
+ }
+
+ public ClientIdentifierApi clientIdentifier() {
+ return create(ClientIdentifierApi.class);
+ }
+
+ public ClientSearchV2Api clientSearchV2() {
+ return create(ClientSearchV2Api.class);
+ }
+
+ public ClientTransactionApi clientTransaction() {
+ return create(ClientTransactionApi.class);
+ }
+
+ public ClientsAddressApi clientsAddress() {
+ return create(ClientsAddressApi.class);
+ }
+
+ public CodeValuesApi codeValues() {
+ return create(CodeValuesApi.class);
+ }
+
+ public CodesApi codes() {
+ return create(CodesApi.class);
+ }
+
+ public CollateralManagementApi collateralManagement() {
+ return create(CollateralManagementApi.class);
+ }
+
+ public CollectionSheetApi collectionSheet() {
+ return create(CollectionSheetApi.class);
+ }
+
+ public CreditBureauConfigurationApi creditBureauConfiguration() {
+ return create(CreditBureauConfigurationApi.class);
+ }
+
+ public CurrencyApi currency() {
+ return create(CurrencyApi.class);
+ }
+
+ public DataTablesApi dataTables() {
+ return create(DataTablesApi.class);
+ }
+
+ public DefaultApi defaultApi() {
+ return create(DefaultApi.class);
+ }
+
+ public DelinquencyRangeAndBucketsManagementApi delinquencyRangeAndBucketsManagement() {
+ return create(DelinquencyRangeAndBucketsManagementApi.class);
+ }
+
+ public DepositAccountOnHoldFundTransactionsApi depositAccountOnHoldFundTransactions() {
+ return create(DepositAccountOnHoldFundTransactionsApi.class);
+ }
+
+ public DeviceRegistrationApi deviceRegistration() {
+ return create(DeviceRegistrationApi.class);
+ }
+
+ public DocumentsApi documents() {
+ return create(DocumentsApi.class);
+ }
+
+ public DocumentsApiFixed documentsFixed() {
+ return create(DocumentsApiFixed.class);
+ }
+
+ public EntityDataTableApi entityDataTable() {
+ return create(EntityDataTableApi.class);
+ }
+
+ public EntityFieldConfigurationApi entityFieldConfiguration() {
+ return create(EntityFieldConfigurationApi.class);
+ }
+
+ public ExternalAssetOwnerLoanProductAttributesApi externalAssetOwnerLoanProductAttributes() {
+ return create(ExternalAssetOwnerLoanProductAttributesApi.class);
+ }
+
+ public ExternalAssetOwnersApi externalAssetOwners() {
+ return create(ExternalAssetOwnersApi.class);
+ }
+
+ public ExternalEventConfigurationApi externalEventConfiguration() {
+ return create(ExternalEventConfigurationApi.class);
+ }
+
+ public ExternalServicesApi externalServices() {
+ return create(ExternalServicesApi.class);
+ }
+
+ public FetchAuthenticatedUserDetailsApi fetchAuthenticatedUserDetails() {
+ return create(FetchAuthenticatedUserDetailsApi.class);
+ }
+
+ public FineractEntityApi fineractEntity() {
+ return create(FineractEntityApi.class);
+ }
+
+ public FixedDepositAccountApi fixedDepositAccount() {
+ return create(FixedDepositAccountApi.class);
+ }
+
+ public FixedDepositAccountTransactionsApi fixedDepositAccountTransactions() {
+ return create(FixedDepositAccountTransactionsApi.class);
+ }
+
+ public FixedDepositProductApi fixedDepositProduct() {
+ return create(FixedDepositProductApi.class);
+ }
+
+ public FloatingRatesApi floatingRates() {
+ return create(FloatingRatesApi.class);
+ }
+
+ public FundsApi funds() {
+ return create(FundsApi.class);
+ }
+
+ public GeneralLedgerAccountApi generalLedgerAccount() {
+ return create(GeneralLedgerAccountApi.class);
+ }
+
+ public GlobalConfigurationApi globalConfiguration() {
+ return create(GlobalConfigurationApi.class);
+ }
+
+ public GroupsApi groups() {
+ return create(GroupsApi.class);
+ }
+
+ public GroupsLevelApi groupsLevel() {
+ return create(GroupsLevelApi.class);
+ }
+
+ public GuarantorsApi guarantors() {
+ return create(GuarantorsApi.class);
+ }
+
+ public HolidaysApi holidays() {
+ return create(HolidaysApi.class);
+ }
+
+ public HooksApi hooks() {
+ return create(HooksApi.class);
+ }
+
+ public ImagesApi images() {
+ return create(ImagesApi.class);
+ }
+
+ public InlineJobApi inlineJob() {
+ return create(InlineJobApi.class);
+ }
+
+ public InstanceModeApi instanceMode() {
+ return create(InstanceModeApi.class);
+ }
+
+ public InterOperationApi interOperation() {
+ return create(InterOperationApi.class);
+ }
+
+ public InterestRateChartApi interestRateChart() {
+ return create(InterestRateChartApi.class);
+ }
+
+ public InterestRateSlabAKAInterestBandsApi interestRateSlabAKAInterestBands() {
+ return create(InterestRateSlabAKAInterestBandsApi.class);
+ }
+
+ public InternalCobApi internalCob() {
+ return create(InternalCobApi.class);
+ }
+
+ public JournalEntriesApi journalEntries() {
+ return create(JournalEntriesApi.class);
+ }
+
+ public LikelihoodApi likelihood() {
+ return create(LikelihoodApi.class);
+ }
+
+ public ListReportMailingJobHistoryApi listReportMailingJobHistory() {
+ return create(ListReportMailingJobHistoryApi.class);
+ }
+
+ public LoanAccountLockApi loanAccountLock() {
+ return create(LoanAccountLockApi.class);
+ }
+
+ public LoanBuyDownFeesApi loanBuyDownFees() {
+ return create(LoanBuyDownFeesApi.class);
+ }
+
+ public LoanCapitalizedIncomeApi loanCapitalizedIncome() {
+ return create(LoanCapitalizedIncomeApi.class);
+ }
+
+ public LoanChargesApi loanCharges() {
+ return create(LoanChargesApi.class);
+ }
+
+ public LoanCobCatchUpApi loanCobCatchUp() {
+ return create(LoanCobCatchUpApi.class);
+ }
+
+ public LoanCollateralApi loanCollateral() {
+ return create(LoanCollateralApi.class);
+ }
+
+ public LoanCollateralManagementApi loanCollateralManagement() {
+ return create(LoanCollateralManagementApi.class);
+ }
+
+ public LoanDisbursementDetailsApi loanDisbursementDetails() {
+ return create(LoanDisbursementDetailsApi.class);
+ }
+
+ public LoanInterestPauseApi loanInterestPause() {
+ return create(LoanInterestPauseApi.class);
+ }
+
+ public LoanProductsApi loanProducts() {
+ return create(LoanProductsApi.class);
+ }
+
+ public LoanReschedulingApi loanRescheduling() {
+ return create(LoanReschedulingApi.class);
+ }
+
+ public LoanTransactionsApi loanTransactions() {
+ return create(LoanTransactionsApi.class);
+ }
+
+ public LoansApi loans() {
+ return create(LoansApi.class);
+ }
+
+ public LoansPointInTimeApi loansPointInTime() {
+ return create(LoansPointInTimeApi.class);
+ }
+
+ public MakerCheckerOr4EyeFunctionalityApi makerCheckerOr4EyeFunctionality() {
+ return create(MakerCheckerOr4EyeFunctionalityApi.class);
+ }
+
+ public MappingFinancialActivitiesToAccountsApi mappingFinancialActivitiesToAccounts() {
+ return create(MappingFinancialActivitiesToAccountsApi.class);
+ }
+
+ public MeetingsApi meetings() {
+ return create(MeetingsApi.class);
+ }
+
+ public MixMappingApi mixMapping() {
+ return create(MixMappingApi.class);
+ }
+
+ public MixReportApi mixReport() {
+ return create(MixReportApi.class);
+ }
+
+ public MixTaxonomyApi mixTaxonomy() {
+ return create(MixTaxonomyApi.class);
+ }
+
+ public NotesApi notes() {
+ return create(NotesApi.class);
+ }
+
+ public NotificationApi notification() {
+ return create(NotificationApi.class);
+ }
+
+ public OfficesApi offices() {
+ return create(OfficesApi.class);
+ }
+
+ public PasswordPreferencesApi passwordPreferences() {
+ return create(PasswordPreferencesApi.class);
+ }
+
+ public PaymentTypeApi paymentType() {
+ return create(PaymentTypeApi.class);
+ }
+
+ public PeriodicAccrualAccountingApi periodicAccrualAccounting() {
+ return create(PeriodicAccrualAccountingApi.class);
+ }
+
+ public PermissionsApi permissions() {
+ return create(PermissionsApi.class);
+ }
+
+ public PocketApi pocket() {
+ return create(PocketApi.class);
+ }
+
+ public PovertyLineApi povertyLine() {
+ return create(PovertyLineApi.class);
+ }
+
+ public ProductMixApi productMix() {
+ return create(ProductMixApi.class);
+ }
+
+ public ProductsApi products() {
+ return create(ProductsApi.class);
+ }
+
+ public ProgressiveLoanApi progressiveLoan() {
+ return create(ProgressiveLoanApi.class);
+ }
+
+ public ProvisioningCategoryApi provisioningCategory() {
+ return create(ProvisioningCategoryApi.class);
+ }
+
+ public ProvisioningCriteriaApi provisioningCriteria() {
+ return create(ProvisioningCriteriaApi.class);
+ }
+
+ public ProvisioningEntriesApi provisioningEntries() {
+ return create(ProvisioningEntriesApi.class);
+ }
+
+ public RateApi rate() {
+ return create(RateApi.class);
+ }
+
+ public RecurringDepositAccountApi recurringDepositAccount() {
+ return create(RecurringDepositAccountApi.class);
+ }
+
+ public RecurringDepositAccountTransactionsApi recurringDepositAccountTransactions() {
+ return create(RecurringDepositAccountTransactionsApi.class);
+ }
+
+ public RecurringDepositProductApi recurringDepositProduct() {
+ return create(RecurringDepositProductApi.class);
+ }
+
+ public RepaymentWithPostDatedChecksApi repaymentWithPostDatedChecks() {
+ return create(RepaymentWithPostDatedChecksApi.class);
+ }
+
+ public ReportMailingJobsApi reportMailingJobs() {
+ return create(ReportMailingJobsApi.class);
+ }
+
+ public ReportsApi reports() {
+ return create(ReportsApi.class);
+ }
+
+ public RescheduleLoansApi rescheduleLoans() {
+ return create(RescheduleLoansApi.class);
+ }
+
+ public RolesApi roles() {
+ return create(RolesApi.class);
+ }
+
+ public RunReportsApi runReports() {
+ return create(RunReportsApi.class);
+ }
+
+ public SavingsAccountApi savingsAccount() {
+ return create(SavingsAccountApi.class);
+ }
+
+ public SavingsAccountTransactionsApi savingsAccountTransactions() {
+ return create(SavingsAccountTransactionsApi.class);
+ }
+
+ public SavingsChargesApi savingsCharges() {
+ return create(SavingsChargesApi.class);
+ }
+
+ public SavingsProductApi savingsProduct() {
+ return create(SavingsProductApi.class);
+ }
+
+ public SchedulerApi scheduler() {
+ return create(SchedulerApi.class);
+ }
+
+ public SchedulerJobApi schedulerJob() {
+ return create(SchedulerJobApi.class);
+ }
+
+ public ScoreCardApi scoreCard() {
+ return create(ScoreCardApi.class);
+ }
+
+ public SearchApiApi search() {
+ return create(SearchApiApi.class);
+ }
+
+ public SelfAccountTransferApi selfAccountTransfer() {
+ return create(SelfAccountTransferApi.class);
+ }
+
+ public SelfAuthenticationApi selfAuthentication() {
+ return create(SelfAuthenticationApi.class);
+ }
+
+ public SelfClientApi selfClient() {
+ return create(SelfClientApi.class);
+ }
+
+ public SelfDividendApi selfDividend() {
+ return create(SelfDividendApi.class);
+ }
+
+ public SelfLoanProductsApi selfLoanProducts() {
+ return create(SelfLoanProductsApi.class);
+ }
+
+ public SelfLoansApi selfLoans() {
+ return create(SelfLoansApi.class);
+ }
+
+ public SelfRunReportApi selfRunReport() {
+ return create(SelfRunReportApi.class);
+ }
+
+ public SelfSavingsAccountApi selfSavingsAccount() {
+ return create(SelfSavingsAccountApi.class);
+ }
+
+ public SelfSavingsProductsApi selfSavingsProducts() {
+ return create(SelfSavingsProductsApi.class);
+ }
+
+ public SelfScoreCardApi selfScoreCard() {
+ return create(SelfScoreCardApi.class);
+ }
+
+ public SelfServiceRegistrationApi selfServiceRegistration() {
+ return create(SelfServiceRegistrationApi.class);
+ }
+
+ public SelfShareAccountsApi selfShareAccounts() {
+ return create(SelfShareAccountsApi.class);
+ }
+
+ public SelfShareProductsApi selfShareProducts() {
+ return create(SelfShareProductsApi.class);
+ }
+
+ public SelfSpmApi selfSpm() {
+ return create(SelfSpmApi.class);
+ }
+
+ public SelfThirdPartyTransferApi selfThirdPartyTransfer() {
+ return create(SelfThirdPartyTransferApi.class);
+ }
+
+ public SelfUserApi selfUser() {
+ return create(SelfUserApi.class);
+ }
+
+ public SelfUserDetailsApi selfUserDetails() {
+ return create(SelfUserDetailsApi.class);
+ }
+
+ public ShareAccountApi shareAccount() {
+ return create(ShareAccountApi.class);
+ }
+
+ public SmsApi sms() {
+ return create(SmsApi.class);
+ }
+
+ public SpmApiLookUpTableApi spmApiLookUpTable() {
+ return create(SpmApiLookUpTableApi.class);
+ }
+
+ public SpmSurveysApi spmSurveys() {
+ return create(SpmSurveysApi.class);
+ }
+
+ public StaffApi staff() {
+ return create(StaffApi.class);
+ }
+
+ public StandingInstructionsApi standingInstructions() {
+ return create(StandingInstructionsApi.class);
+ }
+
+ public StandingInstructionsHistoryApi standingInstructionsHistory() {
+ return create(StandingInstructionsHistoryApi.class);
+ }
+
+ public SurveyApi survey() {
+ return create(SurveyApi.class);
+ }
+
+ public TaxComponentsApi taxComponents() {
+ return create(TaxComponentsApi.class);
+ }
+
+ public TaxGroupApi taxGroup() {
+ return create(TaxGroupApi.class);
+ }
+
+ public TellerCashManagementApi tellerCashManagement() {
+ return create(TellerCashManagementApi.class);
+ }
+
+ public TwoFactorApi twoFactor() {
+ return create(TwoFactorApi.class);
+ }
+
+ public UserGeneratedDocumentsApi userGeneratedDocuments() {
+ return create(UserGeneratedDocumentsApi.class);
+ }
+
+ public UsersApi users() {
+ return create(UsersApi.class);
+ }
+
+ public WorkingDaysApi workingDays() {
+ return create(WorkingDaysApi.class);
+ }
+
+ /**
+ * Builder for creating and configuring a FineractFeignClient.
+ */
+ public static class Builder {
+
+ private final FineractFeignClientConfig.Builder configBuilder = FineractFeignClientConfig.builder();
+
+ /**
+ * Sets the base URL for the Fineract API.
+ *
+ * @param baseUrl
+ * The base URL (e.g., "https://localhost:8443/fineract-provider/api/v1")
+ * @return This builder instance
+ */
+ public Builder baseUrl(String baseUrl) {
+ configBuilder.baseUrl(baseUrl);
+ return this;
+ }
+
+ /**
+ * Sets the credentials for Basic Authentication.
+ *
+ * @param username
+ * The username
+ * @param password
+ * The password
+ * @return This builder instance
+ */
+ public Builder credentials(String username, String password) {
+ configBuilder.credentials(username, password);
+ return this;
+ }
+
+ /**
+ * Sets the connection timeout.
+ *
+ * @param timeout
+ * The timeout value
+ * @param unit
+ * The time unit
+ * @return This builder instance
+ */
+ public Builder connectTimeout(int timeout, java.util.concurrent.TimeUnit unit) {
+ configBuilder.connectTimeout(timeout, unit);
+ return this;
+ }
+
+ /**
+ * Sets the read timeout.
+ *
+ * @param timeout
+ * The timeout value
+ * @param unit
+ * The time unit
+ * @return This builder instance
+ */
+ public Builder readTimeout(int timeout, java.util.concurrent.TimeUnit unit) {
+ configBuilder.readTimeout(timeout, unit);
+ return this;
+ }
+
+ /**
+ * Enables or disables debug logging.
+ *
+ * @param enabled
+ * true to enable debug logging, false to disable
+ * @return This builder instance
+ */
+ public Builder debug(boolean enabled) {
+ configBuilder.debugEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Disables SSL certificate verification. Use only for testing with self-signed certificates.
+ *
+ * @param disable
+ * true to disable SSL verification, false to enable
+ * @return This builder instance
+ */
+ public Builder disableSslVerification(boolean disable) {
+ configBuilder.disableSslVerification(disable);
+ return this;
+ }
+
+ public Builder tenantId(String tenantId) {
+ configBuilder.tenantId(tenantId);
+ return this;
+ }
+
+ /**
+ * Sets the connection time-to-live (TTL) for connection pool recycling.
+ *
+ * @param ttl
+ * The time-to-live value
+ * @param unit
+ * The time unit
+ * @return This builder instance
+ */
+ public Builder connectionTimeToLive(long ttl, java.util.concurrent.TimeUnit unit) {
+ configBuilder.connectionTimeToLive(ttl, unit);
+ return this;
+ }
+
+ /**
+ * Sets the idle connection eviction time.
+ *
+ * @param time
+ * The eviction time value
+ * @param unit
+ * The time unit
+ * @return This builder instance
+ */
+ public Builder idleConnectionEvictionTime(long time, java.util.concurrent.TimeUnit unit) {
+ configBuilder.idleConnectionEvictionTime(time, unit);
+ return this;
+ }
+
+ /**
+ * Sets the maximum total connections in the pool.
+ *
+ * @param max
+ * Maximum total connections
+ * @return This builder instance
+ */
+ public Builder maxConnections(int max) {
+ configBuilder.maxConnTotal(max);
+ return this;
+ }
+
+ /**
+ * Sets the maximum connections per route.
+ *
+ * @param max
+ * Maximum connections per route
+ * @return This builder instance
+ */
+ public Builder maxConnectionsPerRoute(int max) {
+ configBuilder.maxConnPerRoute(max);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP client type.
+ *
+ * @param clientType
+ * The HTTP client type (APACHE or OKHTTP)
+ * @return This builder instance
+ */
+ public Builder httpClientType(FineractFeignClientConfig.HttpClientType clientType) {
+ configBuilder.httpClientType(clientType);
+ return this;
+ }
+
+ /**
+ * Builds a new FineractFeignClient with the current configuration.
+ *
+ * @return A new FineractFeignClient instance
+ */
+ public FineractFeignClient build() {
+ return new FineractFeignClient(this);
+ }
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClientConfig.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClientConfig.java
new file mode 100644
index 0000000000..04fe8e1ef8
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClientConfig.java
@@ -0,0 +1,293 @@
+/**
+ * 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.client.feign;
+
+import feign.Client;
+import feign.Feign;
+import feign.Request;
+import feign.Retryer;
+import feign.codec.Encoder;
+import feign.hc5.ApacheHttp5Client;
+import feign.jackson.JacksonDecoder;
+import feign.jackson.JacksonEncoder;
+import feign.slf4j.Slf4jLogger;
+import java.security.cert.X509Certificate;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Configuration class for Feign client.
+ */
+public final class FineractFeignClientConfig {
+
+ public enum HttpClientType {
+ APACHE, OKHTTP
+ }
+
+ private final String baseUrl;
+ private final String username;
+ private final String password;
+ private final String tenantId;
+ private final int connectTimeout;
+ private final int readTimeout;
+ private final boolean debugEnabled;
+ private final long connectionTimeToLive;
+ private final TimeUnit connectionTimeToLiveUnit;
+ private final boolean disableSslVerification;
+ private final int maxConnTotal;
+ private final int maxConnPerRoute;
+ private final long idleConnectionEvictionTime;
+ private final TimeUnit idleConnectionEvictionTimeUnit;
+ private final HttpClientType clientType;
+ private volatile Client cachedHttpClient;
+
+ private FineractFeignClientConfig(Builder builder) {
+ this.baseUrl = builder.baseUrl;
+ this.username = builder.username;
+ this.password = builder.password;
+ this.tenantId = builder.tenantId;
+ this.connectTimeout = builder.connectTimeout;
+ this.readTimeout = builder.readTimeout;
+ this.debugEnabled = builder.debugEnabled;
+ this.connectionTimeToLive = builder.connectionTimeToLive;
+ this.connectionTimeToLiveUnit = builder.connectionTimeToLiveUnit;
+ this.disableSslVerification = builder.disableSslVerification;
+ this.maxConnTotal = builder.maxConnTotal;
+ this.maxConnPerRoute = builder.maxConnPerRoute;
+ this.idleConnectionEvictionTime = builder.idleConnectionEvictionTime;
+ this.idleConnectionEvictionTimeUnit = builder.idleConnectionEvictionTimeUnit;
+ this.clientType = builder.clientType;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public <T> T createClient(Class<T> apiType) {
+ JacksonEncoder jacksonEncoder = new JacksonEncoder(ObjectMapperFactory.getShared());
+ Encoder multipartEncoder = new FineractMultipartEncoder(jacksonEncoder);
+
+ return Feign.builder().client(getOrCreateHttpClient()).encoder(multipartEncoder)
+ .decoder(new JacksonDecoder(ObjectMapperFactory.getShared())).errorDecoder(new FineractErrorDecoder())
+ .options(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, true))
+ .retryer(Retryer.NEVER_RETRY).requestInterceptor(new BasicAuthRequestInterceptor(username, password))
+ .requestInterceptor(new TenantIdRequestInterceptor(tenantId)).logger(new Slf4jLogger(apiType))
+ .logLevel(debugEnabled ? feign.Logger.Level.FULL : feign.Logger.Level.BASIC).target(apiType, baseUrl);
+ }
+
+ private Client getOrCreateHttpClient() {
+ if (cachedHttpClient == null) {
+ synchronized (this) {
+ if (cachedHttpClient == null) {
+ cachedHttpClient = createHttpClient();
+ }
+ }
+ }
+ return cachedHttpClient;
+ }
+
+ private Client createHttpClient() {
+ switch (clientType) {
+ case APACHE:
+ return createApacheHttpClient();
+ case OKHTTP:
+ return createOkHttpClient();
+ default:
+ throw new IllegalStateException("Unsupported HTTP client type: " + clientType);
+ }
+ }
+
+ private Client createApacheHttpClient() {
+ try {
+ PoolingHttpClientConnectionManagerBuilder connManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create()
+ .setMaxConnTotal(maxConnTotal).setMaxConnPerRoute(maxConnPerRoute);
+
+ if (disableSslVerification) {
+ SSLContext sslContext = createTrustAllSslContext();
+ SSLConnectionSocketFactory sslSocketFactory = SSLConnectionSocketFactoryBuilder.create().setSslContext(sslContext).build();
+ connManagerBuilder.setSSLSocketFactory(sslSocketFactory);
+ }
+
+ if (connectionTimeToLive > 0) {
+ connManagerBuilder.setConnectionTimeToLive(TimeValue.of(connectionTimeToLive, connectionTimeToLiveUnit));
+ }
+
+ PoolingHttpClientConnectionManager connectionManager = connManagerBuilder.build();
+
+ CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(connectionManager)
+ .setDefaultRequestConfig(RequestConfig.custom().setConnectTimeout(Timeout.ofMilliseconds(connectTimeout))
+ .setResponseTimeout(Timeout.ofMilliseconds(readTimeout)).build())
+ .evictIdleConnections(TimeValue.of(idleConnectionEvictionTime, idleConnectionEvictionTimeUnit))
+ .evictExpiredConnections().build();
+
+ return new ApacheHttp5Client(httpClient);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to create Apache HTTP client", e);
+ }
+ }
+
+ private Client createOkHttpClient() {
+ try {
+ okhttp3.OkHttpClient.Builder builder = new okhttp3.OkHttpClient.Builder().connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
+ .readTimeout(readTimeout, TimeUnit.MILLISECONDS)
+ .connectionPool(new okhttp3.ConnectionPool(maxConnTotal, connectionTimeToLive > 0 ? connectionTimeToLive : 5,
+ connectionTimeToLive > 0 ? connectionTimeToLiveUnit : TimeUnit.MINUTES));
+
+ if (disableSslVerification) {
+ SSLContext sslContext = createTrustAllSslContext();
+ builder.sslSocketFactory(sslContext.getSocketFactory(), createTrustAllManager());
+ builder.hostnameVerifier((hostname, session) -> true);
+ }
+
+ return new feign.okhttp.OkHttpClient(builder.build());
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to create OkHttp client", e);
+ }
+ }
+
+ private X509TrustManager createTrustAllManager() {
+ return new X509TrustManager() {
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] certs, String authType) {}
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] certs, String authType) {}
+ };
+ }
+
+ private SSLContext createTrustAllSslContext() throws Exception {
+ TrustManager[] trustAllCerts = new TrustManager[] { createTrustAllManager() };
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
+ return sslContext;
+ }
+
+ public static class Builder {
+
+ private String baseUrl;
+ private String username;
+ private String password;
+ private String tenantId = "default";
+ private int connectTimeout = 30000; // 30 seconds
+ private int readTimeout = 60000; // 60 seconds
+ private boolean debugEnabled = false;
+ private long connectionTimeToLive = -1;
+ private TimeUnit connectionTimeToLiveUnit = TimeUnit.MILLISECONDS;
+ private boolean disableSslVerification = false;
+ private int maxConnTotal = 200;
+ private int maxConnPerRoute = 20;
+ private long idleConnectionEvictionTime = 30;
+ private TimeUnit idleConnectionEvictionTimeUnit = TimeUnit.SECONDS;
+ private HttpClientType clientType = HttpClientType.APACHE;
+
+ public Builder baseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ return this;
+ }
+
+ public Builder credentials(String username, String password) {
+ this.username = username;
+ this.password = password;
+ return this;
+ }
+
+ public Builder tenantId(String tenantId) {
+ this.tenantId = tenantId;
+ return this;
+ }
+
+ public Builder connectTimeout(int timeout, TimeUnit unit) {
+ this.connectTimeout = (int) unit.toMillis(timeout);
+ return this;
+ }
+
+ public Builder readTimeout(int timeout, TimeUnit unit) {
+ this.readTimeout = (int) unit.toMillis(timeout);
+ return this;
+ }
+
+ public Builder connectionTimeToLive(long ttl, TimeUnit unit) {
+ this.connectionTimeToLive = ttl;
+ this.connectionTimeToLiveUnit = unit;
+ return this;
+ }
+
+ public Builder debugEnabled(boolean debugEnabled) {
+ this.debugEnabled = debugEnabled;
+ return this;
+ }
+
+ public Builder disableSslVerification(boolean disableSslVerification) {
+ this.disableSslVerification = disableSslVerification;
+ return this;
+ }
+
+ public Builder maxConnTotal(int maxConnTotal) {
+ this.maxConnTotal = maxConnTotal;
+ return this;
+ }
+
+ public Builder maxConnPerRoute(int maxConnPerRoute) {
+ this.maxConnPerRoute = maxConnPerRoute;
+ return this;
+ }
+
+ public Builder idleConnectionEvictionTime(long time, TimeUnit unit) {
+ this.idleConnectionEvictionTime = time;
+ this.idleConnectionEvictionTimeUnit = unit;
+ return this;
+ }
+
+ public Builder httpClientType(HttpClientType clientType) {
+ this.clientType = clientType;
+ return this;
+ }
+
+ public FineractFeignClientConfig build() {
+ if (baseUrl == null || baseUrl.trim().isEmpty()) {
+ throw new IllegalStateException("baseUrl is required");
+ }
+ if (username == null || username.trim().isEmpty()) {
+ throw new IllegalStateException("username is required");
+ }
+ if (password == null) {
+ throw new IllegalStateException("password is required");
+ }
+ return new FineractFeignClientConfig(this);
+ }
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractMultipartEncoder.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractMultipartEncoder.java
new file mode 100644
index 0000000000..7f81679002
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractMultipartEncoder.java
@@ -0,0 +1,198 @@
+/**
+ * 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.client.feign;
+
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
+import org.apache.hc.core5.http.HttpEntity;
+
+/**
+ * Custom multipart encoder for Fineract Feign clients. Uses Apache HttpClient's MultipartEntityBuilder to properly
+ * construct multipart/form-data requests.
+ */
+public class FineractMultipartEncoder implements Encoder {
+
+ private final Encoder delegate;
+
+ public FineractMultipartEncoder(Encoder delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
+ if (object instanceof MultipartData) {
+ encodeMultipart((MultipartData) object, template);
+ } else if (object instanceof File) {
+ encodeFileAsMultipart((File) object, template);
+ } else if (object instanceof String && template.headers().containsKey("Content-Type")) {
+ String contentType = template.headers().get("Content-Type").iterator().next();
+ if (contentType.startsWith("text/html") || contentType.startsWith("text/plain")) {
+ byte[] bodyBytes = ((String) object).getBytes(StandardCharsets.UTF_8);
+ template.body(bodyBytes, StandardCharsets.UTF_8);
+ } else {
+ delegate.encode(object, bodyType, template);
+ }
+ } else {
+ delegate.encode(object, bodyType, template);
+ }
+ }
+
+ private void encodeFileAsMultipart(File file, RequestTemplate template) throws EncodeException {
+ try {
+ byte[] fileData = Files.readAllBytes(file.toPath());
+ String contentType = detectContentType(file);
+ MultipartData multipartData = new MultipartData().addFile("file", file.getName(), fileData, contentType);
+ encodeMultipart(multipartData, template);
+ } catch (IOException e) {
+ throw new EncodeException("Failed to encode File as multipart: " + file, e);
+ }
+ }
+
+ private String detectContentType(File file) {
+ try {
+ String contentType = Files.probeContentType(file.toPath());
+ return contentType != null ? contentType : "application/octet-stream";
+ } catch (IOException e) {
+ return "application/octet-stream";
+ }
+ }
+
+ private void encodeMultipart(MultipartData data, RequestTemplate template) throws EncodeException {
+ try {
+ MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+ builder.setMode(org.apache.hc.client5.http.entity.mime.HttpMultipartMode.STRICT);
+
+ for (MultipartData.Part part : data.getParts()) {
+ if (part.getFileData() != null) {
+ org.apache.hc.core5.http.ContentType ct = org.apache.hc.core5.http.ContentType.create(part.getContentType());
+ builder.addBinaryBody(part.getName(), part.getFileData(), ct, part.getFileName());
+ } else if (part.getTextValue() != null) {
+ builder.addTextBody(part.getName(), part.getTextValue(), org.apache.hc.core5.http.ContentType.TEXT_PLAIN);
+ }
+ }
+
+ HttpEntity entity = builder.build();
+
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ entity.writeTo(outputStream);
+ byte[] body = outputStream.toByteArray();
+
+ template.body(body, null);
+ template.removeHeader("Content-Type");
+ String contentTypeValue = entity.getContentType();
+ String cleanContentType = contentTypeValue;
+ if (contentTypeValue.contains(";")) {
+ int firstSemicolon = contentTypeValue.indexOf(';');
+ String mainType = contentTypeValue.substring(0, firstSemicolon).trim();
+ String paramsSection = contentTypeValue.substring(firstSemicolon + 1);
+
+ String boundary = null;
+ int boundaryIndex = paramsSection.indexOf("boundary=");
+ if (boundaryIndex != -1) {
+ int boundaryStart = boundaryIndex;
+ int boundaryEnd = paramsSection.indexOf(';', boundaryStart);
+ if (boundaryEnd == -1) {
+ boundary = paramsSection.substring(boundaryStart).trim();
+ } else {
+ boundary = paramsSection.substring(boundaryStart, boundaryEnd).trim();
+ }
+ }
+
+ if (boundary != null) {
+ cleanContentType = mainType + "; " + boundary;
+ }
+ }
+ template.header("Content-Type", cleanContentType);
+ } catch (IOException e) {
+ throw new EncodeException("Failed to encode multipart request", e);
+ }
+ }
+
+ public static class MultipartData {
+
+ private final java.util.List<Part> parts = new java.util.ArrayList<>();
+
+ public MultipartData addFile(String name, String fileName, byte[] data, String contentType) {
+ parts.add(new Part(name, fileName, data, contentType));
+ return this;
+ }
+
+ public MultipartData addText(String name, String value) {
+ parts.add(new Part(name, value));
+ return this;
+ }
+
+ public java.util.List<Part> getParts() {
+ return parts;
+ }
+
+ public static class Part {
+
+ private final String name;
+ private final String fileName;
+ private final byte[] fileData;
+ private final String contentType;
+ private final String textValue;
+
+ public Part(String name, String fileName, byte[] fileData, String contentType) {
+ this.name = name;
+ this.fileName = fileName;
+ this.fileData = fileData;
+ this.contentType = contentType;
+ this.textValue = null;
+ }
+
+ public Part(String name, String textValue) {
+ this.name = name;
+ this.textValue = textValue;
+ this.fileName = null;
+ this.fileData = null;
+ this.contentType = null;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public byte[] getFileData() {
+ return fileData;
+ }
+
+ public String getContentType() {
+ return contentType;
+ }
+
+ public String getTextValue() {
+ return textValue;
+ }
+ }
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/ObjectMapperFactory.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/ObjectMapperFactory.java
new file mode 100644
index 0000000000..18e163041e
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/ObjectMapperFactory.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.client.feign;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.apache.fineract.client.adapter.ExternalIdAdapter;
+
+/**
+ * Factory for creating and configuring Jackson ObjectMapper instances.
+ */
+public final class ObjectMapperFactory {
+
+ private static final ObjectMapper INSTANCE = createObjectMapper();
+
+ private ObjectMapperFactory() {
+ // Private constructor to prevent instantiation
+ }
+
+ /**
+ * Creates and configures a new ObjectMapper instance.
+ *
+ * @return A new configured ObjectMapper instance
+ */
+ public static ObjectMapper createObjectMapper() {
+ ObjectMapper mapper = new ObjectMapper();
+
+ // Configure the ObjectMapper
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ mapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
+ mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+
+ // Register Java 8 date/time support
+ mapper.registerModule(new JavaTimeModule());
+
+ // Register ExternalId adapter
+ mapper.registerModule(ExternalIdAdapter.createModule());
+
+ // Disable FAIL_ON_EMPTY_BEANS for empty responses
+ mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
+
+ return mapper;
+ }
+
+ /**
+ * Returns a shared, pre-configured ObjectMapper instance.
+ *
+ * @return A shared ObjectMapper instance
+ */
+ public static ObjectMapper getShared() {
+ return INSTANCE;
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/TenantIdRequestInterceptor.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/TenantIdRequestInterceptor.java
new file mode 100644
index 0000000000..15578cd3ec
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/TenantIdRequestInterceptor.java
@@ -0,0 +1,42 @@
+/**
+ * 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.client.feign;
+
+import feign.RequestInterceptor;
+import feign.RequestTemplate;
+
+public class TenantIdRequestInterceptor implements RequestInterceptor {
+
+ private final String tenantId;
+
+ public TenantIdRequestInterceptor(String tenantId) {
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ public void apply(RequestTemplate template) {
+ template.header("Fineract-Platform-TenantId", tenantId);
+ if (!template.headers().containsKey("Content-Type")) {
+ template.header("Content-Type", "application/json");
+ }
+ if (!template.headers().containsKey("Accept")) {
+ template.header("Accept", "application/json");
+ }
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/DocumentsApiFixed.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/DocumentsApiFixed.java
new file mode 100644
index 0000000000..de3f7934a0
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/DocumentsApiFixed.java
@@ -0,0 +1,140 @@
+/**
+ * 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.client.feign.services;
+
+import feign.Param;
+import feign.RequestLine;
+import feign.Response;
+import java.util.List;
+import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
+import org.apache.fineract.client.models.DocumentData;
+import org.apache.fineract.client.models.PostEntityTypeEntityIdDocumentsResponse;
+import org.apache.fineract.client.models.PutEntityTypeEntityIdDocumentsResponse;
+
+/**
+ * This class was originally generated by OpenAPI Generator (https://openapi-generator.tech), but then had to be
+ * manually edited to manually fix https://issues.apache.org/jira/browse/FINERACT-1227. If we could fix our OpenAPI /
+ * Swagger YAML generation from the JAX RS and OpenAPI annotation to have the correct notation for binary document files
+ * and images, then this could be removed again.
+ */
+public interface DocumentsApiFixed {
+
+ /**
+ * Create a Document Note: A document is created using a Multi-part form upload Body Parts name : Name or summary of
+ * the document description : Description of the document file : The file to be uploaded Mandatory Fields : file and
+ * description
+ *
+ * @param entityType
+ * entityType (required)
+ * @param entityId
+ * entityId (required)
+ * @param file
+ * (required)
+ * @param name
+ * name (optional)
+ * @param description
+ * description (optional)
+ * @return PostEntityTypeEntityIdDocumentsResponse
+ */
+ @RequestLine("POST /v1/{entityType}/{entityId}/documents")
+ PostEntityTypeEntityIdDocumentsResponse createDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
+ org.apache.fineract.client.feign.FineractMultipartEncoder.MultipartData multipartData);
+
+ /**
+ * Remove a Document
+ *
+ * @param entityType
+ * entityType (required)
+ * @param entityId
+ * entityId (required)
+ * @param documentId
+ * documentId (required)
+ * @return DeleteEntityTypeEntityIdDocumentsResponse
+ */
+ @RequestLine("DELETE /v1/{entityType}/{entityId}/documents/{documentId}")
+ DeleteEntityTypeEntityIdDocumentsResponse deleteDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
+ @Param("documentId") Long documentId);
+
+ /**
+ * Retrieve Binary File associated with Document Request used to download the file associated with the document
+ * Example Requests: clients/1/documents/1/attachment loans/1/documents/1/attachment
+ *
+ * @param entityType
+ * entityType (required)
+ * @param entityId
+ * entityId (required)
+ * @param documentId
+ * documentId (required)
+ * @return Response
+ */
+ @RequestLine("GET /v1/{entityType}/{entityId}/documents/{documentId}/attachment")
+ @feign.Headers("Accept: */*")
+ Response downloadFile(@Param("entityType") String entityType, @Param("entityId") Long entityId, @Param("documentId") Long documentId);
+
+ /**
+ * Retrieve a Document Example Requests: clients/1/documents/1 loans/1/documents/1
+ * client_identifiers/1/documents/1?fields=name,description
+ *
+ * @param entityType
+ * entityType (required)
+ * @param entityId
+ * entityId (required)
+ * @param documentId
+ * documentId (required)
+ * @return DocumentData
+ */
+ @RequestLine("GET /v1/{entityType}/{entityId}/documents/{documentId}")
+ DocumentData getDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
+ @Param("documentId") Long documentId);
+
+ /**
+ * List documents Example Requests: clients/1/documents client_identifiers/1/documents
+ * loans/1/documents?fields=name,description
+ *
+ * @param entityType
+ * entityType (required)
+ * @param entityId
+ * entityId (required)
+ * @return List<DocumentData>
+ */
+ @RequestLine("GET /v1/{entityType}/{entityId}/documents")
+ List<DocumentData> retrieveAllDocuments(@Param("entityType") String entityType, @Param("entityId") Long entityId);
+
+ /**
+ * Update a Document Note: A document is updated using a Multi-part form upload Body Parts name Name or summary of
+ * the document description Description of the document file The file to be uploaded
+ *
+ * @param entityType
+ * entityType (required)
+ * @param entityId
+ * entityId (required)
+ * @param documentId
+ * documentId (required)
+ * @param file
+ * (optional)
+ * @param name
+ * name (optional)
+ * @param description
+ * description (optional)
+ * @return PutEntityTypeEntityIdDocumentsResponse
+ */
+ @RequestLine("PUT /v1/{entityType}/{entityId}/documents/{documentId}")
+ PutEntityTypeEntityIdDocumentsResponse updateDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
+ @Param("documentId") Long documentId, org.apache.fineract.client.feign.FineractMultipartEncoder.MultipartData multipartData);
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/ImagesApi.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/ImagesApi.java
new file mode 100644
index 0000000000..6fa311e8da
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/ImagesApi.java
@@ -0,0 +1,97 @@
+/**
+ * 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.client.feign.services;
+
+import feign.Headers;
+import feign.Param;
+import feign.QueryMap;
+import feign.RequestLine;
+import feign.Response;
+import java.io.File;
+import java.util.Map;
+
+/**
+ * Client API (Feign) for /images.
+ *
+ * This class is entirely hand-written, inspired by DocumentsApiFixed, and from /images methods which currently end up
+ * in DefaultApi (see <a href="https://issues.apache.org/jira/browse/FINERACT-1222">FINERACT-1222</a>), but fixed for
+ * bugs in the code generation (see <a href="https://issues.apache.org/jira/browse/FINERACT-1227">FINERACT-1227</a>).
+ *
+ * Note: For image uploads, use {@link #prepareFileUpload(File)} to prepare file data as a Data URL.
+ */
+public interface ImagesApi {
+
+ @RequestLine("POST /v1/{entityType}/{entityId}/images")
+ @Headers("Content-Type: text/html")
+ Response create(@Param("entityType") String entityType, @Param("entityId") Long entityId, String dataUrl);
+
+ @RequestLine("GET /v1/{entityType}/{entityId}/images")
+ Response get(@Param("entityType") String entityType, @Param("entityId") Long entityId, @QueryMap Map<String, Object> queryParams);
+
+ @RequestLine("PUT /v1/{entityType}/{entityId}/images")
+ @Headers("Content-Type: text/html")
+ Response update(@Param("entityType") String entityType, @Param("entityId") Long entityId, String dataUrl);
+
+ @RequestLine("DELETE /v1/{entityType}/{entityId}/images")
+ Response delete(@Param("entityType") String entityType, @Param("entityId") Long entityId);
+
+ static String prepareFileUpload(File file) {
+ try {
+ byte[] fileData = java.nio.file.Files.readAllBytes(file.toPath());
+ String contentType = detectMediaType(file.getName());
+ String dataUrlPrefix = getDataUrlPrefix(contentType);
+ String base64Data = java.util.Base64.getEncoder().encodeToString(fileData);
+ return dataUrlPrefix + base64Data;
+ } catch (java.io.IOException e) {
+ throw new RuntimeException("Failed to prepare file for upload: " + file, e);
+ }
+ }
+
+ private static String getDataUrlPrefix(String contentType) {
+ return "data:" + contentType + ";base64,";
+ }
+
+ private static String detectMediaType(String fileName) {
+ if (fileName == null) {
+ return "application/octet-stream";
+ }
+ int dotPos = fileName.lastIndexOf('.');
+ if (dotPos == -1) {
+ return "application/octet-stream";
+ }
+ String ext = fileName.substring(dotPos + 1).toLowerCase();
+
+ switch (ext) {
+ case "jpg":
+ case "jpeg":
+ return "image/jpeg";
+ case "png":
+ return "image/png";
+ case "gif":
+ return "image/gif";
+ case "tif":
+ case "tiff":
+ return "image/tiff";
+ case "pdf":
+ return "application/pdf";
+ default:
+ return "application/octet-stream";
+ }
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/RunReportsApi.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/RunReportsApi.java
new file mode 100644
index 0000000000..dfcaa298b6
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/RunReportsApi.java
@@ -0,0 +1,66 @@
+/**
+ * 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.client.feign.services;
+
+import feign.Param;
+import feign.QueryMap;
+import feign.RequestLine;
+import feign.Response;
+import java.util.Map;
+import org.apache.fineract.client.models.RunReportsResponse;
+
+public interface RunReportsApi {
+
+ /**
+ * Running a Report This resource allows you to run and receive output from pre-defined Apache Fineract reports.
+ * Reports can also be used to provide data for searching and workflow functionality. The default output is a JSON
+ * formatted "Generic Resultset". The Generic Resultset contains Column Heading as well as Data information.
+ * However, you can export to CSV format by simply adding "&exportCSV=true" to the end of your URL. If Pentaho
+ * reports have been pre-defined, they can also be run through this resource. Pentaho reports can return HTML, PDF
+ * or CSV formats. The Apache Fineract reference application uses a JQuery plugin called stretchy reporting which,
+ * itself, uses this reports resource to provide a pretty flexible reporting User Interface (UI). Example Requests:
+ * runreports/Client%20Listing?R_officeId=1 runreports/Client%20Listing?R_officeId=1&exportCSV=true
+ * runreports/OfficeIdSelectOne?R_officeId=1&parameterType=true
+ * runreports/OfficeIdSelectOne?R_officeId=1&parameterType=true&exportCSV=true
+ * runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&R_loanOfficerId=-1&R_officeId=1&R_startDate=2013-04-16&output-type=HTML&R_officeId=1
+ * runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&R_loanOfficerId=-1&R_officeId=1&R_startDate=2013-04-16&output-type=XLS&R_officeId=1
+ * runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&R_loanOfficerId=-1&R_officeId=1&R_startDate=2013-04-16&output-type=CSV&R_officeId=1
+ * runreports/Expected%20Payments%20By%20Date%20-%20Formatted?R_endDate=2013-04-30&R_loanOfficerId=-1&R_officeId=1&R_startDate=2013-04-16&output-type=PDF&R_officeId=1
+ *
+ * @param reportName
+ * reportName (required)
+ * @param parameters
+ * Dynamic query parameters for the report (required)
+ * @return RunReportsResponse
+ */
+ @RequestLine("GET /v1/runreports/{reportName}")
+ RunReportsResponse runReportGetData(@Param("reportName") String reportName, @QueryMap Map<String, String> parameters);
+
+ /**
+ * Run Report which returns a response such as a PDF, CSV, XLS or XLSX file.
+ *
+ * @param reportName
+ * reportName (required)
+ * @param parameters
+ * Dynamic query parameters for the report (required)
+ * @return Response containing the file content
+ */
+ @RequestLine("GET /v1/runreports/{reportName}")
+ Response runReportGetFile(@Param("reportName") String reportName, @QueryMap Map<String, String> parameters);
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/CallFailedRuntimeException.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/CallFailedRuntimeException.java
new file mode 100644
index 0000000000..c126899bb3
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/CallFailedRuntimeException.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.client.feign.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import feign.FeignException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Exception thrown by {@link FeignCalls} utility when Feign calls fail.
+ */
+@Slf4j
+@Getter
+public class CallFailedRuntimeException extends RuntimeException {
+
+ private final int status;
+ private final String developerMessage;
+
+ public CallFailedRuntimeException(FeignException cause) {
+ super(createMessage(cause), cause);
+ this.status = cause.status();
+ this.developerMessage = extractDeveloperMessage(cause);
+ }
+
+ private static String createMessage(FeignException e) {
+ StringBuilder sb = new StringBuilder("HTTP failed: status=").append(e.status());
+
+ if (e.request() != null) {
+ sb.append(", request=").append(e.request().url());
+ }
+
+ String contentString = e.contentUTF8();
+ if (contentString != null && !contentString.isEmpty()) {
+ sb.append(", errorBody=").append(contentString);
+ }
+
+ return sb.toString();
+ }
+
+ private static String extractDeveloperMessage(FeignException e) {
+ try {
+ byte[] content = e.content();
+ if (content == null || content.length == 0) {
+ return e.getMessage();
+ }
+
+ String contentString = new String(content, StandardCharsets.UTF_8);
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode root = mapper.readTree(contentString);
+
+ if (root.has("developerMessage")) {
+ return root.get("developerMessage").asText();
+ }
+
+ if (root.has("errors")) {
+ JsonNode errors = root.get("errors");
+ if (errors.isArray() && errors.size() > 0) {
+ JsonNode firstError = errors.get(0);
+ if (firstError.has("developerMessage")) {
+ return firstError.get("developerMessage").asText();
+ }
+ }
+ }
+
+ return contentString;
+ } catch (IOException ex) {
+ log.warn("Failed to extract developer message from error response", ex);
+ return e.getMessage();
+ }
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/FeignCalls.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/FeignCalls.java
new file mode 100644
index 0000000000..950c459c46
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/FeignCalls.java
@@ -0,0 +1,80 @@
+/**
+ * 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.client.feign.util;
+
+import feign.FeignException;
+import java.util.function.Supplier;
+
+/**
+ * Extension methods for Feign calls. This class is recommended to be statically imported.
+ */
+public final class FeignCalls {
+
+ private FeignCalls() {}
+
+ /**
+ * Execute a Feign call, expecting success, returning strongly typed body. This covers the most typical use and is
+ * thus most used.
+ *
+ * @param feignCall
+ * the Feign call to execute
+ * @return the result of the successful call (never null)
+ * @throws CallFailedRuntimeException
+ * thrown if a problem occurred talking to the server, or the HTTP response code was not [200..300)
+ * successful
+ */
+ public static <T> T ok(Supplier<T> feignCall) throws CallFailedRuntimeException {
+ try {
+ return feignCall.get();
+ } catch (FeignException e) {
+ throw new CallFailedRuntimeException(e);
+ }
+ }
+
+ /**
+ * Execute a Feign call that returns void. This is useful for operations that don't return a response body.
+ *
+ * @param feignCall
+ * the Feign call to execute
+ * @throws CallFailedRuntimeException
+ * thrown if a problem occurred talking to the server, or the HTTP response code was not [200..300)
+ * successful
+ */
+ public static void executeVoid(Runnable feignCall) throws CallFailedRuntimeException {
+ try {
+ feignCall.run();
+ } catch (FeignException e) {
+ throw new CallFailedRuntimeException(e);
+ }
+ }
+
+ /**
+ * Execute a Feign call, returning the result without throwing CallFailedRuntimeException. This is useful when you
+ * want to handle FeignException yourself.
+ *
+ * @param feignCall
+ * the Feign call to execute
+ * @return the result of the call
+ * @throws FeignException
+ * thrown if a problem occurred talking to the server
+ */
+ public static <T> T execute(Supplier<T> feignCall) throws FeignException {
+ return feignCall.get();
+ }
+}
diff --git a/fineract-client-feign/src/main/java/org/apache/fineract/client/util/FeignParts.java b/fineract-client-feign/src/main/java/org/apache/fineract/client/util/FeignParts.java
new file mode 100644
index 0000000000..cd7cd80bf3
--- /dev/null
+++ b/fineract-client-feign/src/main/java/org/apache/fineract/client/util/FeignParts.java
@@ -0,0 +1,136 @@
+/**
+ * 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.client.util;
+
+import feign.Response;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Optional;
+import org.apache.fineract.client.feign.services.DocumentsApiFixed;
+import org.apache.fineract.client.feign.services.ImagesApi;
+
+/**
+ * Convenience utilities for file handling in Feign API calls.
+ *
+ * Provides helper methods for: - Media type detection from file extensions - File name extraction from HTTP response
+ * headers - Content type probing
+ *
+ * Used in conjunction with {@link DocumentsApiFixed} and {@link ImagesApi} which use Data URL format for file uploads
+ * (base64-encoded strings with data URI scheme).
+ */
+public final class FeignParts {
+
+ private FeignParts() {}
+
+ /**
+ * Determine the media type based on file extension.
+ *
+ * @param fileName
+ * the name of the file
+ * @return the media type string, or null if not recognized
+ */
+ public static String mediaType(String fileName) {
+ if (fileName == null) {
+ return null;
+ }
+ int dotPos = fileName.lastIndexOf('.');
+ if (dotPos == -1) {
+ return null;
+ }
+ String ext = fileName.substring(dotPos + 1).toLowerCase();
+
+ switch (ext) {
+ case "jpg":
+ case "jpeg":
+ return "image/jpeg";
+ case "png":
+ return "image/png";
+ case "tif":
+ case "tiff":
+ return "image/tiff";
+ case "gif":
+ return "image/gif";
+ case "pdf":
+ return "application/pdf";
+ case "docx":
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+ case "doc":
+ return "application/msword";
+ case "xlsx":
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ case "xls":
+ return "application/vnd.ms-excel";
+ case "odt":
+ return "application/vnd.oasis.opendocument.text";
+ case "ods":
+ return "application/vnd.oasis.opendocument.spreadsheet";
+ case "txt":
+ return "text/plain";
+ default:
+ return "application/octet-stream";
+ }
+ }
+
+ /**
+ * Extract the file name from the Content-Disposition header of a response.
+ *
+ * @param response
+ * the HTTP response
+ * @return Optional containing the file name if present
+ */
+ public static Optional<String> fileName(Response response) {
+ if (response.headers() == null) {
+ return Optional.empty();
+ }
+
+ java.util.Collection<String> contentDispositionHeaders = response.headers().get("Content-Disposition");
+ if (contentDispositionHeaders == null || contentDispositionHeaders.isEmpty()) {
+ return Optional.empty();
+ }
+
+ String contentDisposition = contentDispositionHeaders.iterator().next();
+ if (contentDisposition == null) {
+ return Optional.empty();
+ }
+
+ int i = contentDisposition.indexOf("; filename=\"");
+ if (i == -1) {
+ return Optional.empty();
+ }
+ return Optional.of(contentDisposition.substring(i + "; filename=\"".length(), contentDisposition.length() - 1));
+ }
+
+ /**
+ * Probe the content type of a file using Files.probeContentType.
+ *
+ * @param file
+ * the file to probe
+ * @return the content type, or application/octet-stream as fallback
+ * @throws IOException
+ * if an I/O error occurs
+ */
+ public static String probeContentType(File file) throws IOException {
+ String contentType = Files.probeContentType(file.toPath());
+ if (contentType == null) {
+ contentType = mediaType(file.getName());
+ }
+ return contentType != null ? contentType : "application/octet-stream";
+ }
+}
diff --git a/fineract-client-feign/src/main/resources/templates/java/api.mustache b/fineract-client-feign/src/main/resources/templates/java/api.mustache
new file mode 100644
index 0000000000..44edc0a3b7
--- /dev/null
+++ b/fineract-client-feign/src/main/resources/templates/java/api.mustache
@@ -0,0 +1,47 @@
+package {{package}};
+
+import feign.*;
+import feign.Param;
+import feign.RequestLine;
+
+{{#imports}}import {{import}};
+{{/imports}}
+
+import java.util.List;
+import java.util.Map;
+
+public interface {{classname}} {
+
+{{#operations}}
+ {{#operation}}
+ /**
+ * {{summary}}
+ * {{notes}}
+ {{#allParams}}
+ * @param {{paramName}} {{description}}
+ {{/allParams}}
+ * @return {{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}
+ */
+ @RequestLine("{{httpMethod}} {{path}}")
+ {{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{operationId}}(
+ {{#pathParams}}
+ @Param("{{baseName}}") {{{dataType}}} {{paramName}}{{^-last}},{{/-last}}
+ {{/pathParams}}
+ {{#bodyParam}}
+ {{#hasPathParams}},{{/hasPathParams}}{{{dataType}}} {{paramName}}
+ {{/bodyParam}}
+ {{#hasQueryParams}}
+ {{#hasPathParams}}{{^bodyParam}},{{/bodyParam}}{{/hasPathParams}}{{#bodyParam}},{{/bodyParam}}
+ @QueryMap Map<String, Object> queryParams
+ {{/hasQueryParams}}
+ {{#hasHeaderParams}}
+ {{#hasPathParams}}{{^bodyParam}}{{^hasQueryParams}},{{/hasQueryParams}}{{/bodyParam}}{{/hasPathParams}}
+ {{#bodyParam}}{{^hasQueryParams}},{{/hasQueryParams}}{{/bodyParam}}
+ {{#hasQueryParams}},{{/hasQueryParams}}
+ @HeaderMap Map<String, Object> headerParams
+ {{/hasHeaderParams}}
+ );
+
+ {{/operation}}
+{{/operations}}
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/adapter/ExternalIdAdapterTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/adapter/ExternalIdAdapterTest.java
new file mode 100644
index 0000000000..0840a1aa2c
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/adapter/ExternalIdAdapterTest.java
@@ -0,0 +1,192 @@
+/**
+ * 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.client.adapter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.fineract.client.feign.ObjectMapperFactory;
+import org.apache.fineract.client.models.ExternalId;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ExternalIdAdapterTest {
+
+ private ObjectMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = ObjectMapperFactory.getShared();
+ }
+
+ @Test
+ void testSerializeExternalId() throws Exception {
+ ExternalId externalId = new ExternalId().value("EXT-12345");
+
+ String json = mapper.writeValueAsString(externalId);
+
+ assertThat(json).isEqualTo("\"EXT-12345\"");
+ }
+
+ @Test
+ void testSerializeExternalIdWithSpecialCharacters() throws Exception {
+ ExternalId externalId = new ExternalId().value("EXT-ID-2024/01/15");
+
+ String json = mapper.writeValueAsString(externalId);
+
+ assertThat(json).isEqualTo("\"EXT-ID-2024/01/15\"");
+ }
+
+ @Test
+ void testSerializeNullExternalId() throws Exception {
+ ExternalId externalId = new ExternalId().value(null);
+
+ String json = mapper.writeValueAsString(externalId);
+
+ assertThat(json).isEqualTo("null");
+ }
+
+ @Test
+ void testSerializeExternalIdObjectNull() throws Exception {
+ ExternalId externalId = null;
+
+ String json = mapper.writeValueAsString(externalId);
+
+ assertThat(json).isEqualTo("null");
+ }
+
+ @Test
+ void testDeserializeExternalId() throws Exception {
+ String json = "\"EXT-67890\"";
+
+ ExternalId externalId = mapper.readValue(json, ExternalId.class);
+
+ assertThat(externalId).isNotNull();
+ assertThat(externalId.getValue()).isEqualTo("EXT-67890");
+ }
+
+ @Test
+ void testDeserializeExternalIdWithSpecialCharacters() throws Exception {
+ String json = "\"CLIENT-2024-001/ABC\"";
+
+ ExternalId externalId = mapper.readValue(json, ExternalId.class);
+
+ assertThat(externalId).isNotNull();
+ assertThat(externalId.getValue()).isEqualTo("CLIENT-2024-001/ABC");
+ }
+
+ @Test
+ void testDeserializeNullExternalId() throws Exception {
+ String json = "null";
+
+ ExternalId externalId = mapper.readValue(json, ExternalId.class);
+
+ assertThat(externalId).isNull();
+ }
+
+ @Test
+ void testRoundTripSerialization() throws Exception {
+ ExternalId original = new ExternalId().value("TEST-ID-001");
+
+ String json = mapper.writeValueAsString(original);
+ ExternalId deserialized = mapper.readValue(json, ExternalId.class);
+
+ assertThat(deserialized.getValue()).isEqualTo(original.getValue());
+ }
+
+ @Test
+ void testRoundTripSerializationWithUUID() throws Exception {
+ ExternalId original = new ExternalId().value("550e8400-e29b-41d4-a716-446655440000");
+
+ String json = mapper.writeValueAsString(original);
+ ExternalId deserialized = mapper.readValue(json, ExternalId.class);
+
+ assertThat(deserialized.getValue()).isEqualTo(original.getValue());
+ }
+
+ @Test
+ void testRoundTripSerializationWithNumericString() throws Exception {
+ ExternalId original = new ExternalId().value("123456789");
+
+ String json = mapper.writeValueAsString(original);
+ ExternalId deserialized = mapper.readValue(json, ExternalId.class);
+
+ assertThat(deserialized.getValue()).isEqualTo(original.getValue());
+ }
+
+ @Test
+ void testDeserializeEmptyString() throws Exception {
+ String json = "\"\"";
+
+ ExternalId externalId = mapper.readValue(json, ExternalId.class);
+
+ assertThat(externalId).isNotNull();
+ assertThat(externalId.getValue()).isEmpty();
+ }
+
+ @Test
+ void testSerializeExternalIdWithEmptyValue() throws Exception {
+ ExternalId externalId = new ExternalId().value("");
+
+ String json = mapper.writeValueAsString(externalId);
+
+ assertThat(json).isEqualTo("\"\"");
+ }
+
+ @Test
+ void testModuleCreation() {
+ assertThat(ExternalIdAdapter.createModule()).isNotNull();
+ assertThat(ExternalIdAdapter.createModule().getModuleName()).isEqualTo("ExternalIdModule");
+ }
+
+ @Test
+ void testDeserializeExternalIdWithWhitespace() throws Exception {
+ String json = "\" EXT-001 \"";
+
+ ExternalId externalId = mapper.readValue(json, ExternalId.class);
+
+ assertThat(externalId).isNotNull();
+ assertThat(externalId.getValue()).isEqualTo(" EXT-001 ");
+ }
+
+ @Test
+ void testSerializeExternalIdInObject() throws Exception {
+ TestObject obj = new TestObject();
+ obj.externalId = new ExternalId().value("TEST-123");
+
+ String json = mapper.writeValueAsString(obj);
+
+ assertThat(json).contains("\"externalId\":\"TEST-123\"");
+ }
+
+ @Test
+ void testDeserializeExternalIdInObject() throws Exception {
+ String json = "{\"externalId\":\"TEST-456\"}";
+
+ TestObject obj = mapper.readValue(json, TestObject.class);
+
+ assertThat(obj.externalId).isNotNull();
+ assertThat(obj.externalId.getValue()).isEqualTo("TEST-456");
+ }
+
+ static class TestObject {
+
+ public ExternalId externalId;
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FeignExceptionTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FeignExceptionTest.java
new file mode 100644
index 0000000000..a61d5e0f1e
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FeignExceptionTest.java
@@ -0,0 +1,140 @@
+/**
+ * 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.client.feign;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import feign.Request;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class FeignExceptionTest {
+
+ private Request request;
+ private byte[] responseBody;
+
+ @BeforeEach
+ void setUp() {
+ request = Request.create(Request.HttpMethod.GET, "/api/test", Collections.emptyMap(), null, StandardCharsets.UTF_8, null);
+ responseBody = "Error response body".getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void testConstructorWithErrorDetails() {
+ String developerMessage = "Technical error details";
+ String userMessage = "User-friendly error message";
+
+ FeignException exception = new FeignException(400, "Error message", request, responseBody, developerMessage, userMessage);
+
+ assertNotNull(exception);
+ assertEquals(400, exception.status());
+ assertEquals(developerMessage, exception.getDeveloperMessage());
+ assertEquals(userMessage, exception.getUserMessage());
+ assertEquals(request, exception.request());
+ assertEquals(responseBody, exception.responseBody());
+ }
+
+ @Test
+ void testGetMessage() {
+ String developerMessage = "Developer error";
+ String userMessage = "User error";
+
+ FeignException exception = new FeignException(500, "Base message", request, responseBody, developerMessage, userMessage);
+
+ String message = exception.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains("500"));
+ assertTrue(message.contains(userMessage));
+ assertTrue(message.contains(developerMessage));
+ }
+
+ @Test
+ void testGetDeveloperMessage() {
+ String developerMessage = "This is a technical error";
+
+ FeignException exception = new FeignException(400, "Error", request, responseBody, developerMessage, null);
+
+ assertEquals(developerMessage, exception.getDeveloperMessage());
+ }
+
+ @Test
+ void testGetUserMessage() {
+ String userMessage = "This is a user-friendly error";
+
+ FeignException exception = new FeignException(400, "Error", request, responseBody, null, userMessage);
+
+ assertEquals(userMessage, exception.getUserMessage());
+ }
+
+ @Test
+ void testNullErrorMessages() {
+ FeignException exception = new FeignException(404, "Not found", request, responseBody, null, null);
+
+ assertNull(exception.getDeveloperMessage());
+ assertNull(exception.getUserMessage());
+ String message = exception.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains("404"));
+ assertTrue(message.contains("Not found"));
+ }
+
+ @Test
+ void testGetMessageWithOnlyUserMessage() {
+ String userMessage = "User error only";
+
+ FeignException exception = new FeignException(400, "Base message", request, responseBody, null, userMessage);
+
+ String message = exception.getMessage();
+ assertTrue(message.contains("400"));
+ assertTrue(message.contains(userMessage));
+ }
+
+ @Test
+ void testGetMessageWithOnlyDeveloperMessage() {
+ String developerMessage = "Developer error only";
+
+ FeignException exception = new FeignException(400, "Base message", request, responseBody, developerMessage, null);
+
+ String message = exception.getMessage();
+ assertTrue(message.contains("400"));
+ assertTrue(message.contains(developerMessage));
+ }
+
+ @Test
+ void testResponseBodyAsString() {
+ FeignException exception = new FeignException(400, "Error", request, responseBody, null, null);
+
+ String bodyString = exception.responseBodyAsString();
+ assertNotNull(bodyString);
+ assertEquals("Error response body", bodyString);
+ }
+
+ @Test
+ void testResponseBodyAsStringWithNullBody() {
+ FeignException exception = new FeignException(400, "Error", request, null, null, null);
+
+ String bodyString = exception.responseBodyAsString();
+ assertNull(bodyString);
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractErrorDecoderTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractErrorDecoderTest.java
new file mode 100644
index 0000000000..59d580fe88
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractErrorDecoderTest.java
@@ -0,0 +1,180 @@
+/**
+ * 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.client.feign;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import feign.Request;
+import feign.Response;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class FineractErrorDecoderTest {
+
+ private FineractErrorDecoder decoder;
+ private Request request;
+
+ @BeforeEach
+ void setUp() {
+ decoder = new FineractErrorDecoder();
+ request = Request.create(Request.HttpMethod.GET, "/api/test", Collections.emptyMap(), null, StandardCharsets.UTF_8, null);
+ }
+
+ @Test
+ void testDecodeValidFineractError() {
+ String jsonError = "{\"developerMessage\":\"Developer error message\",\"userMessage\":\"User error message\"}";
+ Response response = createResponse(400, jsonError);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertInstanceOf(FeignException.class, exception);
+ FeignException feignException = (FeignException) exception;
+ assertEquals(400, feignException.status());
+ assertEquals("User error message", feignException.getUserMessage());
+ assertEquals("Developer error message", feignException.getDeveloperMessage());
+ }
+
+ @Test
+ void testDecodeInvalidJson() {
+ String invalidJson = "This is not valid JSON";
+ Response response = createResponse(400, invalidJson);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertNotNull(exception);
+ }
+
+ @Test
+ void testDecodeNullBody() {
+ Response response = createResponse(404, null);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertNotNull(exception);
+ }
+
+ @Test
+ void testDecode400Error() {
+ String jsonError = "{\"developerMessage\":\"Bad request details\",\"userMessage\":\"Invalid input\"}";
+ Response response = createResponse(400, jsonError);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertInstanceOf(FeignException.class, exception);
+ FeignException feignException = (FeignException) exception;
+ assertEquals(400, feignException.status());
+ }
+
+ @Test
+ void testDecode404Error() {
+ String jsonError = "{\"developerMessage\":\"Resource not found details\",\"userMessage\":\"Not found\"}";
+ Response response = createResponse(404, jsonError);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertInstanceOf(FeignException.class, exception);
+ FeignException feignException = (FeignException) exception;
+ assertEquals(404, feignException.status());
+ }
+
+ @Test
+ void testDecode500Error() {
+ String jsonError = "{\"developerMessage\":\"Internal server error details\",\"userMessage\":\"Server error\"}";
+ Response response = createResponse(500, jsonError);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertInstanceOf(FeignException.class, exception);
+ FeignException feignException = (FeignException) exception;
+ assertEquals(500, feignException.status());
+ }
+
+ @Test
+ void testExtractDeveloperMessage() {
+ String jsonError = "{\"developerMessage\":\"Technical details here\"}";
+ Response response = createResponse(400, jsonError);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertInstanceOf(FeignException.class, exception);
+ FeignException feignException = (FeignException) exception;
+ assertEquals("Technical details here", feignException.getDeveloperMessage());
+ }
+
+ @Test
+ void testExtractUserMessage() {
+ String jsonError = "{\"userMessage\":\"User-friendly message\"}";
+ Response response = createResponse(400, jsonError);
+
+ Exception exception = decoder.decode("Test#method", response);
+
+ assertInstanceOf(FeignException.class, exception);
+ FeignException feignException = (FeignException) exception;
+ assertEquals("User-friendly message", feignException.getUserMessage());
+ }
+
+ private Response createResponse(int status, String body) {
+ Map<String, Collection<String>> headers = new HashMap<>();
+
+ Response.Body responseBody = null;
+ if (body != null) {
+ byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
+ responseBody = new Response.Body() {
+
+ @Override
+ public Integer length() {
+ return bodyBytes.length;
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return true;
+ }
+
+ @Override
+ public ByteArrayInputStream asInputStream() {
+ return new ByteArrayInputStream(bodyBytes);
+ }
+
+ @Override
+ public java.io.Reader asReader() {
+ return new java.io.InputStreamReader(asInputStream(), StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public java.io.Reader asReader(java.nio.charset.Charset charset) {
+ return new java.io.InputStreamReader(asInputStream(), charset);
+ }
+
+ @Override
+ public void close() {}
+ };
+ }
+
+ return Response.builder().status(status).reason("Test").request(request).headers(headers).body(responseBody).build();
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractFeignClientConfigTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractFeignClientConfigTest.java
new file mode 100644
index 0000000000..d2de297de5
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractFeignClientConfigTest.java
@@ -0,0 +1,93 @@
+/**
+ * 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.client.feign;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import feign.RequestLine;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class FineractFeignClientConfigTest {
+
+ @Test
+ void testBuilderDefaults() {
+ FineractFeignClientConfig.Builder builder = FineractFeignClientConfig.builder();
+ FineractFeignClientConfig config = builder.baseUrl("http://localhost:8080").credentials("admin", "password").build();
+
+ assertNotNull(config);
+ }
+
+ @Test
+ void testBuilderConfiguration() {
+ String baseUrl = "http://example.com:8080";
+ String username = "testuser";
+ String password = "testpass";
+ int connectTimeoutSeconds = 10;
+ int readTimeoutSeconds = 30;
+
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials(username, password)
+ .connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS).readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
+ .debugEnabled(true).build();
+
+ assertNotNull(config);
+ }
+
+ @Test
+ void testConnectionTimeToLiveConfiguration() {
+ long ttl = 5;
+ TimeUnit ttlUnit = TimeUnit.MINUTES;
+
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
+ .credentials("admin", "password").connectionTimeToLive(ttl, ttlUnit).build();
+
+ assertNotNull(config);
+ }
+
+ @Test
+ void testEncoderDecoderConfiguration() {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
+ .credentials("admin", "password").build();
+
+ assertNotNull(config);
+ }
+
+ @Test
+ void testErrorDecoderConfiguration() {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
+ .credentials("admin", "password").build();
+
+ assertNotNull(config);
+ }
+
+ @Test
+ void testClientCreation() {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:8080")
+ .credentials("admin", "password").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ TestApi client = config.createClient(TestApi.class);
+ assertNotNull(client);
+ }
+
+ interface TestApi {
+
+ @RequestLine("GET /test")
+ String test();
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/ObjectMapperFactoryTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/ObjectMapperFactoryTest.java
new file mode 100644
index 0000000000..237ee4779f
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/ObjectMapperFactoryTest.java
@@ -0,0 +1,120 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.client.feign;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.time.LocalDate;
+import org.junit.jupiter.api.Test;
+
+class ObjectMapperFactoryTest {
+
+ @Test
+ void testSharedObjectMapperNotNull() {
+ ObjectMapper mapper = ObjectMapperFactory.getShared();
+ assertNotNull(mapper);
+ }
+
+ @Test
+ void testSharedObjectMapperSingleton() {
+ ObjectMapper mapper1 = ObjectMapperFactory.getShared();
+ ObjectMapper mapper2 = ObjectMapperFactory.getShared();
+ assertSame(mapper1, mapper2);
+ }
+
+ @Test
+ void testJava8DateTimeSerialization() throws JsonProcessingException {
+ ObjectMapper mapper = ObjectMapperFactory.getShared();
+ LocalDate date = LocalDate.of(2024, 1, 15);
+
+ String json = mapper.writeValueAsString(new TestObject(date));
+ assertNotNull(json);
+ }
+
+ @Test
+ void testJava8DateTimeDeserialization() throws IOException {
+ ObjectMapper mapper = ObjectMapperFactory.getShared();
+ String json = "{\"date\":\"2024-01-15\"}";
+
+ TestObject obj = mapper.readValue(json, TestObject.class);
+ assertNotNull(obj);
+ assertNotNull(obj.getDate());
+ assertEquals(LocalDate.of(2024, 1, 15), obj.getDate());
+ }
+
+ @Test
+ void testNullHandling() throws JsonProcessingException {
+ ObjectMapper mapper = ObjectMapperFactory.getShared();
+ TestObject obj = new TestObject(null);
+
+ String json = mapper.writeValueAsString(obj);
+ assertNotNull(json);
+ }
+
+ @Test
+ void testDeserializeNullValue() throws IOException {
+ ObjectMapper mapper = ObjectMapperFactory.getShared();
+ String json = "{\"date\":null}";
+
+ TestObject obj = mapper.readValue(json, TestObject.class);
+ assertNotNull(obj);
+ assertNull(obj.getDate());
+ }
+
+ @Test
+ void testUnknownPropertiesIgnored() throws IOException {
+ ObjectMapper mapper = ObjectMapperFactory.getShared();
+ String json = "{\"date\":\"2024-01-15\",\"unknownField\":\"value\"}";
+
+ TestObject obj = mapper.readValue(json, TestObject.class);
+ assertNotNull(obj);
+ assertEquals(LocalDate.of(2024, 1, 15), obj.getDate());
+ }
+
+ @Test
+ void testCreateObjectMapperNotNull() {
+ ObjectMapper mapper = ObjectMapperFactory.createObjectMapper();
+ assertNotNull(mapper);
+ }
+
+ static class TestObject {
+
+ private LocalDate date;
+
+ TestObject() {}
+
+ TestObject(LocalDate date) {
+ this.date = date;
+ }
+
+ public LocalDate getDate() {
+ return date;
+ }
+
+ public void setDate(LocalDate date) {
+ this.date = date;
+ }
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/EncoderDecoderIntegrationTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/EncoderDecoderIntegrationTest.java
new file mode 100644
index 0000000000..e486de21b5
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/EncoderDecoderIntegrationTest.java
@@ -0,0 +1,421 @@
+/**
+ * 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.client.feign.integration;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import feign.RequestLine;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("integration")
+class EncoderDecoderIntegrationTest {
+
+ private WireMockServer wireMockServer;
+ private String baseUrl;
+ private FineractFeignClientConfig config;
+
+ @BeforeEach
+ void setUp() {
+ wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ wireMockServer.start();
+ baseUrl = "http://localhost:" + wireMockServer.port();
+
+ config = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials("testuser", "testpass")
+ .connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ }
+
+ @Test
+ void testComplexObjectSerialization() {
+ wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(aResponse().withStatus(201)
+ .withHeader("Content-Type", "application/json").withBody("{\"id\":100,\"status\":\"created\"}")));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("complex-test");
+ request.setAmount(new BigDecimal("1234.56"));
+ request.setCreatedDate(LocalDate.of(2024, 1, 15));
+ request.setItems(Arrays.asList("item1", "item2", "item3"));
+
+ Map<String, String> metadata = new HashMap<>();
+ metadata.put("key1", "value1");
+ metadata.put("key2", "value2");
+ request.setMetadata(metadata);
+
+ NestedObject nested = new NestedObject();
+ nested.setNestedName("nested-value");
+ nested.setNestedDate(LocalDate.of(2024, 2, 20));
+ request.setNested(nested);
+
+ SimpleResponse response = client.createComplex(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getId()).isEqualTo(100L);
+ assertThat(response.getStatus()).isEqualTo("created");
+ }
+
+ @Test
+ void testNullValueHandling() {
+ wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":null}")));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("null-test");
+ request.setAmount(null);
+ request.setCreatedDate(null);
+ request.setItems(null);
+ request.setMetadata(null);
+ request.setNested(null);
+
+ SimpleResponse response = client.createComplex(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getId()).isEqualTo(1L);
+ assertThat(response.getStatus()).isNull();
+ }
+
+ @Test
+ void testDateFormats() {
+ String responseJson = "{\"id\":1,\"date\":\"2024-03-15\",\"dateTime\":\"2024-03-15T14:30:00\",\"timestamp\":\"2024-03-15T14:30:00.123\"}";
+
+ wireMockServer.stubFor(post(urlEqualTo("/date-test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseJson)));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("date-test");
+ request.setCreatedDate(LocalDate.of(2024, 3, 15));
+
+ DateResponse response = client.testDates(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getDate()).isEqualTo(LocalDate.of(2024, 3, 15));
+ assertThat(response.getDateTime()).isEqualTo(LocalDateTime.of(2024, 3, 15, 14, 30, 0));
+ assertThat(response.getTimestamp()).isNotNull();
+ }
+
+ @Test
+ void testLargePayload() {
+ StringBuilder largeString = new StringBuilder();
+ for (int i = 0; i < 1000; i++) {
+ largeString.append("This is line ").append(i).append(". ");
+ }
+
+ wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(aResponse().withStatus(200)
+ .withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"processed\"}")));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName(largeString.toString());
+ request.setAmount(new BigDecimal("999999.99"));
+ request.setCreatedDate(LocalDate.now(ZoneId.systemDefault()));
+
+ SimpleResponse response = client.createComplex(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatus()).isEqualTo("processed");
+ }
+
+ @Test
+ void testEmptyCollections() {
+ wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"ok\"}")));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("empty-test");
+ request.setItems(Arrays.asList());
+ request.setMetadata(new HashMap<>());
+
+ SimpleResponse response = client.createComplex(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatus()).isEqualTo("ok");
+ }
+
+ @Test
+ void testBigDecimalPrecision() {
+ wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"precise\"}")));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("precision-test");
+ request.setAmount(new BigDecimal("123456789.123456789"));
+
+ SimpleResponse response = client.createComplex(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatus()).isEqualTo("precise");
+ }
+
+ @Test
+ void testNestedObjectDeserialization() {
+ String responseJson = "{\"id\":1,\"nested\":{\"nestedName\":\"deep-value\",\"nestedDate\":\"2024-04-01\"},\"items\":[\"a\",\"b\",\"c\"]}";
+
+ wireMockServer.stubFor(post(urlEqualTo("/nested"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseJson)));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("nested-test");
+
+ NestedResponse response = client.testNested(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getId()).isEqualTo(1L);
+ assertThat(response.getNested()).isNotNull();
+ assertThat(response.getNested().getNestedName()).isEqualTo("deep-value");
+ assertThat(response.getNested().getNestedDate()).isEqualTo(LocalDate.of(2024, 4, 1));
+ assertThat(response.getItems()).containsExactly("a", "b", "c");
+ }
+
+ @Test
+ void testSpecialCharacterHandling() {
+ wireMockServer.stubFor(post(urlEqualTo("/complex")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"id\":1,\"status\":\"special\"}")));
+
+ ComplexApi client = config.createClient(ComplexApi.class);
+
+ ComplexRequest request = new ComplexRequest();
+ request.setName("Test with special chars: <>\"'&\n\t");
+
+ SimpleResponse response = client.createComplex(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatus()).isEqualTo("special");
+ }
+
+ interface ComplexApi {
+
+ @RequestLine("POST /complex")
+ SimpleResponse createComplex(ComplexRequest request);
+
+ @RequestLine("POST /date-test")
+ DateResponse testDates(ComplexRequest request);
+
+ @RequestLine("POST /nested")
+ NestedResponse testNested(ComplexRequest request);
+ }
+
+ static class ComplexRequest {
+
+ private String name;
+ private BigDecimal amount;
+ private LocalDate createdDate;
+ private List<String> items;
+ private Map<String, String> metadata;
+ private NestedObject nested;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public BigDecimal getAmount() {
+ return amount;
+ }
+
+ public void setAmount(BigDecimal amount) {
+ this.amount = amount;
+ }
+
+ public LocalDate getCreatedDate() {
+ return createdDate;
+ }
+
+ public void setCreatedDate(LocalDate createdDate) {
+ this.createdDate = createdDate;
+ }
+
+ public List<String> getItems() {
+ return items;
+ }
+
+ public void setItems(List<String> items) {
+ this.items = items;
+ }
+
+ public Map<String, String> getMetadata() {
+ return metadata;
+ }
+
+ public void setMetadata(Map<String, String> metadata) {
+ this.metadata = metadata;
+ }
+
+ public NestedObject getNested() {
+ return nested;
+ }
+
+ public void setNested(NestedObject nested) {
+ this.nested = nested;
+ }
+ }
+
+ static class NestedObject {
+
+ private String nestedName;
+ private LocalDate nestedDate;
+
+ public String getNestedName() {
+ return nestedName;
+ }
+
+ public void setNestedName(String nestedName) {
+ this.nestedName = nestedName;
+ }
+
+ public LocalDate getNestedDate() {
+ return nestedDate;
+ }
+
+ public void setNestedDate(LocalDate nestedDate) {
+ this.nestedDate = nestedDate;
+ }
+ }
+
+ static class SimpleResponse {
+
+ private Long id;
+ private String status;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+ }
+
+ static class DateResponse {
+
+ private Long id;
+ private LocalDate date;
+ private LocalDateTime dateTime;
+ private LocalDateTime timestamp;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public LocalDate getDate() {
+ return date;
+ }
+
+ public void setDate(LocalDate date) {
+ this.date = date;
+ }
+
+ public LocalDateTime getDateTime() {
+ return dateTime;
+ }
+
+ public void setDateTime(LocalDateTime dateTime) {
+ this.dateTime = dateTime;
+ }
+
+ public LocalDateTime getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(LocalDateTime timestamp) {
+ this.timestamp = timestamp;
+ }
+ }
+
+ static class NestedResponse {
+
+ private Long id;
+ private NestedObject nested;
+ private List<String> items;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public NestedObject getNested() {
+ return nested;
+ }
+
+ public void setNested(NestedObject nested) {
+ this.nested = nested;
+ }
+
+ public List<String> getItems() {
+ return items;
+ }
+
+ public void setItems(List<String> items) {
+ this.items = items;
+ }
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/FineractFeignClientIntegrationTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/FineractFeignClientIntegrationTest.java
new file mode 100644
index 0000000000..290428028b
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/FineractFeignClientIntegrationTest.java
@@ -0,0 +1,252 @@
+/**
+ * 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.client.feign.integration;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import feign.RequestLine;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeUnit;
+import org.apache.fineract.client.feign.FeignException;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("integration")
+class FineractFeignClientIntegrationTest {
+
+ private WireMockServer wireMockServer;
+ private String baseUrl;
+ private FineractFeignClientConfig config;
+
+ @BeforeEach
+ void setUp() {
+ wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ wireMockServer.start();
+ baseUrl = "http://localhost:" + wireMockServer.port();
+
+ config = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials("testuser", "testpass")
+ .connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ }
+
+ @Test
+ void testJsonEncoding() {
+ wireMockServer
+ .stubFor(post(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
+ .withBody("{\"id\":1,\"message\":\"success\",\"timestamp\":\"2024-01-15T10:30:00\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+ TestRequest request = new TestRequest();
+ request.setName("test-name");
+ request.setDate(LocalDate.of(2024, 1, 15));
+
+ TestResponse response = client.createTest(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getId()).isEqualTo(1L);
+ assertThat(response.getMessage()).isEqualTo("success");
+ assertThat(response.getTimestamp()).isEqualTo(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
+ }
+
+ @Test
+ void testJsonDecoding() {
+ wireMockServer
+ .stubFor(get(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
+ .withBody("{\"id\":42,\"message\":\"decoded-message\",\"timestamp\":\"2024-12-25T18:45:30\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+ TestResponse response = client.getTest();
+
+ assertThat(response).isNotNull();
+ assertThat(response.getId()).isEqualTo(42L);
+ assertThat(response.getMessage()).isEqualTo("decoded-message");
+ assertThat(response.getTimestamp()).isEqualTo(LocalDateTime.of(2024, 12, 25, 18, 45, 30));
+ }
+
+ @Test
+ void testJava8DateTimeSerialization() {
+ wireMockServer.stubFor(post(urlEqualTo("/test")).withRequestBody(containing("2024-06-30"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
+ .withBody("{\"id\":99,\"message\":\"date-test\",\"timestamp\":\"2024-06-30T12:00:00\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+ TestRequest request = new TestRequest();
+ request.setName("date-test");
+ request.setDate(LocalDate.of(2024, 6, 30));
+
+ TestResponse response = client.createTest(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getId()).isEqualTo(99L);
+ assertThat(response.getTimestamp()).isNotNull();
+ assertThat(response.getTimestamp().toLocalDate()).isEqualTo(LocalDate.of(2024, 6, 30));
+ }
+
+ @Test
+ void testErrorDecoder() {
+ wireMockServer.stubFor(
+ get(urlEqualTo("/error")).willReturn(aResponse().withStatus(404).withHeader("Content-Type", "application/json").withBody(
+ "{\"developerMessage\":\"Resource not found\",\"httpStatusCode\":\"404\",\"defaultUserMessage\":\"Not Found\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+
+ assertThatThrownBy(client::getError).isInstanceOf(FeignException.class).hasMessageContaining("Resource not found");
+ }
+
+ @Test
+ void testErrorDecoderWithServerError() {
+ wireMockServer.stubFor(
+ get(urlEqualTo("/error")).willReturn(aResponse().withStatus(500).withHeader("Content-Type", "application/json").withBody(
+ "{\"developerMessage\":\"Internal server error occurred\",\"httpStatusCode\":\"500\",\"defaultUserMessage\":\"Server Error\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+
+ assertThatThrownBy(client::getError).isInstanceOf(FeignException.class).hasMessageContaining("Internal server error occurred");
+ }
+
+ @Test
+ void testConnectionTimeToLiveConfiguration() {
+ FineractFeignClientConfig ttlConfig = FineractFeignClientConfig.builder().baseUrl(baseUrl).credentials("testuser", "testpass")
+ .connectionTimeToLive(5, TimeUnit.MINUTES).build();
+
+ wireMockServer
+ .stubFor(get(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
+ .withBody("{\"id\":1,\"message\":\"ttl-test\",\"timestamp\":\"2024-01-01T00:00:00\"}")));
+
+ TestApi client = ttlConfig.createClient(TestApi.class);
+ TestResponse response = client.getTest();
+
+ assertThat(response).isNotNull();
+ assertThat(response.getMessage()).isEqualTo("ttl-test");
+ }
+
+ @Test
+ void testBasicAuthentication() {
+ wireMockServer.stubFor(get(urlEqualTo("/test")).withHeader("Authorization", equalTo("Basic dGVzdHVzZXI6dGVzdHBhc3M="))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
+ .withBody("{\"id\":1,\"message\":\"authenticated\",\"timestamp\":\"2024-01-01T00:00:00\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+ TestResponse response = client.getTest();
+
+ assertThat(response).isNotNull();
+ assertThat(response.getMessage()).isEqualTo("authenticated");
+ }
+
+ @Test
+ void testNullValueHandling() {
+ wireMockServer
+ .stubFor(post(urlEqualTo("/test")).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
+ .withBody("{\"id\":1,\"message\":null,\"timestamp\":\"2024-01-01T00:00:00\"}")));
+
+ TestApi client = config.createClient(TestApi.class);
+ TestRequest request = new TestRequest();
+ request.setName("null-test");
+ request.setDate(null);
+
+ TestResponse response = client.createTest(request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getMessage()).isNull();
+ }
+
+ interface TestApi {
+
+ @RequestLine("GET /test")
+ TestResponse getTest();
+
+ @RequestLine("POST /test")
+ TestResponse createTest(TestRequest request);
+
+ @RequestLine("GET /error")
+ TestResponse getError();
+ }
+
+ static class TestRequest {
+
+ private String name;
+ private LocalDate date;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public LocalDate getDate() {
+ return date;
+ }
+
+ public void setDate(LocalDate date) {
+ this.date = date;
+ }
+ }
+
+ static class TestResponse {
+
+ private Long id;
+ private String message;
+ private LocalDateTime timestamp;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public LocalDateTime getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(LocalDateTime timestamp) {
+ this.timestamp = timestamp;
+ }
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/performance/ConnectionPoolPerformanceTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/performance/ConnectionPoolPerformanceTest.java
new file mode 100644
index 0000000000..74a3121abc
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/feign/performance/ConnectionPoolPerformanceTest.java
@@ -0,0 +1,269 @@
+/**
+ * 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.client.feign.performance;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import feign.RequestLine;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("performance")
+class ConnectionPoolPerformanceTest {
+
+ private WireMockServer wireMockServer;
+
+ @BeforeEach
+ void setUp() {
+ wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ wireMockServer.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ }
+
+ @Test
+ void testConnectionPoolTTL() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectionTimeToLive(2, TimeUnit.SECONDS).connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ String response1 = api.getTest();
+ assertThat(response1).isEqualTo("OK");
+
+ Thread.sleep(3000);
+
+ String response2 = api.getTest();
+ assertThat(response2).isEqualTo("OK");
+ }
+
+ @Test
+ void testConnectionPoolTTLWithMultipleRequests() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectionTimeToLive(1, TimeUnit.SECONDS).connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ for (int i = 0; i < 5; i++) {
+ String response = api.getTest();
+ assertThat(response).isEqualTo("OK");
+ Thread.sleep(1500);
+ }
+ }
+
+ @Test
+ void testConcurrentRequests() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"").withFixedDelay(100)));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ int numRequests = 50;
+ ExecutorService executor = Executors.newFixedThreadPool(10);
+ List<Future<String>> futures = new ArrayList<>();
+
+ long startTime = System.currentTimeMillis();
+
+ for (int i = 0; i < numRequests; i++) {
+ futures.add(executor.submit(api::getTest));
+ }
+
+ for (Future<String> future : futures) {
+ assertThat(future.get()).isEqualTo("OK");
+ }
+
+ long duration = System.currentTimeMillis() - startTime;
+
+ executor.shutdown();
+ executor.awaitTermination(30, TimeUnit.SECONDS);
+
+ assertThat(duration).isLessThan(2000);
+ }
+
+ @Test
+ void testConcurrentRequestsWithHighLoad() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"").withFixedDelay(50)));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ int numRequests = 100;
+ ExecutorService executor = Executors.newFixedThreadPool(20);
+ List<Future<String>> futures = new ArrayList<>();
+
+ long startTime = System.currentTimeMillis();
+
+ for (int i = 0; i < numRequests; i++) {
+ futures.add(executor.submit(api::getTest));
+ }
+
+ int successCount = 0;
+ for (Future<String> future : futures) {
+ if ("OK".equals(future.get())) {
+ successCount++;
+ }
+ }
+
+ long duration = System.currentTimeMillis() - startTime;
+
+ executor.shutdown();
+ executor.awaitTermination(30, TimeUnit.SECONDS);
+
+ assertThat(successCount).isEqualTo(numRequests);
+ assertThat(duration).isLessThan(5000);
+ }
+
+ @Test
+ void testConnectionRecycling() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectionTimeToLive(500, TimeUnit.MILLISECONDS).connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ for (int i = 0; i < 10; i++) {
+ String response = api.getTest();
+ assertThat(response).isEqualTo("OK");
+ Thread.sleep(600);
+ }
+ }
+
+ @Test
+ void testDefaultConnectionTimeToLive() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ String response = api.getTest();
+ assertThat(response).isEqualTo("OK");
+ }
+
+ @Test
+ void testSequentialRequests() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ long startTime = System.currentTimeMillis();
+
+ for (int i = 0; i < 100; i++) {
+ String response = api.getTest();
+ assertThat(response).isEqualTo("OK");
+ }
+
+ long duration = System.currentTimeMillis() - startTime;
+
+ assertThat(duration).isLessThan(5000);
+ }
+
+ @Test
+ void testConnectionPoolWithDifferentEndpoints() throws Exception {
+ FineractFeignClientConfig config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port())
+ .credentials("test", "test").connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ wireMockServer.stubFor(get(urlEqualTo("/test"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK\"")));
+ wireMockServer.stubFor(get(urlEqualTo("/test2"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK2\"")));
+ wireMockServer.stubFor(get(urlEqualTo("/test3"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("\"OK3\"")));
+
+ TestApi api = config.createClient(TestApi.class);
+
+ ExecutorService executor = Executors.newFixedThreadPool(5);
+ List<Future<String>> futures = new ArrayList<>();
+
+ for (int i = 0; i < 30; i++) {
+ int endpoint = i % 3;
+ futures.add(executor.submit(() -> {
+ switch (endpoint) {
+ case 0:
+ return api.getTest();
+ case 1:
+ return api.getTest2();
+ default:
+ return api.getTest3();
+ }
+ }));
+ }
+
+ for (Future<String> future : futures) {
+ assertThat(future.get()).isIn("OK", "OK2", "OK3");
+ }
+
+ executor.shutdown();
+ executor.awaitTermination(30, TimeUnit.SECONDS);
+ }
+
+ interface TestApi {
+
+ @RequestLine("GET /test")
+ String getTest();
+
+ @RequestLine("GET /test2")
+ String getTest2();
+
+ @RequestLine("GET /test3")
+ String getTest3();
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/services/DocumentsApiFixedIntegrationTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/services/DocumentsApiFixedIntegrationTest.java
new file mode 100644
index 0000000000..d19703607a
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/services/DocumentsApiFixedIntegrationTest.java
@@ -0,0 +1,208 @@
+/**
+ * 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.client.services;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import feign.Response;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.apache.fineract.client.feign.services.DocumentsApiFixed;
+import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
+import org.apache.fineract.client.models.DocumentData;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+@Tag("integration")
+class DocumentsApiFixedIntegrationTest {
+
+ @TempDir
+ File tempDir;
+
+ private WireMockServer wireMockServer;
+ private FineractFeignClientConfig config;
+ private File testDocumentFile;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ wireMockServer.start();
+ config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port()).credentials("test", "test")
+ .connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ testDocumentFile = new File(tempDir, "test-doc.pdf");
+ Files.write(testDocumentFile.toPath(), "%PDF-1.4 test content".getBytes(StandardCharsets.UTF_8));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ }
+
+ @Test
+ void testGetDocumentsForClient() {
+ String responseBody = "[{\"id\":1,\"name\":\"Document1\",\"description\":\"Test document\"}]";
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ List<DocumentData> documents = api.retrieveAllDocuments("clients", 123L);
+
+ assertThat(documents).isNotNull();
+ assertThat(documents).hasSize(1);
+ assertThat(documents.get(0).getId()).isEqualTo(1L);
+ }
+
+ @Test
+ void testGetDocumentsForLoan() {
+ String responseBody = "[{\"id\":2,\"name\":\"LoanDocument\",\"description\":\"Loan agreement\"}]";
+ wireMockServer.stubFor(get(urlEqualTo("/v1/loans/456/documents"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ List<DocumentData> documents = api.retrieveAllDocuments("loans", 456L);
+
+ assertThat(documents).isNotNull();
+ assertThat(documents).hasSize(1);
+ assertThat(documents.get(0).getName()).isEqualTo("LoanDocument");
+ }
+
+ @Test
+ void testGetDocumentsEmpty() {
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/999/documents"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("[]")));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ List<DocumentData> documents = api.retrieveAllDocuments("clients", 999L);
+
+ assertThat(documents).isNotNull();
+ assertThat(documents).isEmpty();
+ }
+
+ @Test
+ void testGetSingleDocument() {
+ String responseBody = "{\"id\":1,\"name\":\"Document1\",\"description\":\"Test document\",\"fileName\":\"doc.pdf\"}";
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents/1"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ DocumentData document = api.getDocument("clients", 123L, 1L);
+
+ assertThat(document).isNotNull();
+ assertThat(document.getId()).isEqualTo(1L);
+ assertThat(document.getName()).isEqualTo("Document1");
+ assertThat(document.getFileName()).isEqualTo("doc.pdf");
+ }
+
+ @Test
+ void testDownloadDocument() throws IOException {
+ byte[] docContent = "PDF document content here".getBytes(StandardCharsets.UTF_8);
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents/456/attachment"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/pdf")
+ .withHeader("Content-Disposition", "attachment; filename=\"document.pdf\"").withBody(docContent)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ Response response = api.downloadFile("clients", 123L, 456L);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers()).containsKey("Content-Type");
+ assertThat(response.headers().get("Content-Type")).contains("application/pdf");
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(docContent);
+ }
+
+ @Test
+ void testDeleteDocument() {
+ String responseBody = "{\"resourceId\":456}";
+ wireMockServer.stubFor(delete(urlEqualTo("/v1/clients/123/documents/456"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ DeleteEntityTypeEntityIdDocumentsResponse response = api.deleteDocument("clients", 123L, 456L);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getResourceId()).isEqualTo(456L);
+ }
+
+ @Test
+ void testDeleteDocumentForLoan() {
+ String responseBody = "{\"resourceId\":999}";
+ wireMockServer.stubFor(delete(urlEqualTo("/v1/loans/789/documents/999"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ DeleteEntityTypeEntityIdDocumentsResponse response = api.deleteDocument("loans", 789L, 999L);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getResourceId()).isEqualTo(999L);
+ }
+
+ @Test
+ void testMultipleEntityTypes() {
+ String[] entityTypes = { "clients", "loans", "savings", "groups" };
+ Long[] entityIds = { 100L, 200L, 300L, 400L };
+
+ for (int i = 0; i < entityTypes.length; i++) {
+ String entityType = entityTypes[i];
+ Long entityId = entityIds[i];
+
+ wireMockServer.stubFor(get(urlEqualTo("/v1/" + entityType + "/" + entityId + "/documents"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("[]")));
+ }
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+
+ for (int i = 0; i < entityTypes.length; i++) {
+ List<DocumentData> documents = api.retrieveAllDocuments(entityTypes[i], entityIds[i]);
+ assertThat(documents).isNotNull();
+ assertThat(documents).isEmpty();
+ }
+ }
+
+ @Test
+ void testDownloadTextDocument() throws IOException {
+ String textContent = "Plain text document content";
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/documents/999/attachment"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "text/plain")
+ .withHeader("Content-Disposition", "attachment; filename=\"readme.txt\"").withBody(textContent)));
+
+ DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
+ Response response = api.downloadFile("clients", 123L, 999L);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers().get("Content-Type")).contains("text/plain");
+ String content = new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ assertThat(content).isEqualTo(textContent);
+ }
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/services/ImagesApiIntegrationTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/services/ImagesApiIntegrationTest.java
new file mode 100644
index 0000000000..94c5de229f
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/services/ImagesApiIntegrationTest.java
@@ -0,0 +1,180 @@
+/**
+ * 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.client.services;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import feign.Response;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.apache.fineract.client.feign.services.ImagesApi;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+@Tag("integration")
+class ImagesApiIntegrationTest {
+
+ @TempDir
+ File tempDir;
+
+ private WireMockServer wireMockServer;
+ private FineractFeignClientConfig config;
+ private File testImageFile;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ wireMockServer.start();
+ config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port()).credentials("test", "test")
+ .connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+
+ testImageFile = new File(tempDir, "test-image.jpg");
+ Files.write(testImageFile.toPath(), new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x01, 0x02, 0x03 });
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ }
+
+ @Test
+ void testRetrieveClientImage() throws IOException {
+ byte[] imageData = new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x10, 0x11, 0x12 };
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/images"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(imageData)));
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+ Response response = api.get("clients", 123L, new HashMap<>());
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers()).containsKey("Content-Type");
+ assertThat(response.headers().get("Content-Type")).contains("image/jpeg");
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(imageData);
+ }
+
+ @Test
+ void testRetrieveClientImageWithMaxDimensions() throws IOException {
+ byte[] resizedImage = new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x20, 0x21 };
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/clients/123/images")).withQueryParam("maxWidth", equalTo("100"))
+ .withQueryParam("maxHeight", equalTo("100"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(resizedImage)));
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+ Map<String, Object> params = new HashMap<>();
+ params.put("maxWidth", 100);
+ params.put("maxHeight", 100);
+
+ Response response = api.get("clients", 123L, params);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(resizedImage);
+ }
+
+ @Test
+ void testRetrieveClientImageWithMaxWidthOnly() throws IOException {
+ byte[] resizedImage = new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0x30 };
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/clients/123/images")).withQueryParam("maxWidth", equalTo("200"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(resizedImage)));
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+ Map<String, Object> params = new HashMap<>();
+ params.put("maxWidth", 200);
+
+ Response response = api.get("clients", 123L, params);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(resizedImage);
+ }
+
+ @Test
+ void testDeleteClientImage() {
+ wireMockServer.stubFor(delete(urlEqualTo("/v1/clients/123/images"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"resourceId\":123}")));
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+ Response response = api.delete("clients", 123L);
+
+ assertThat(response.status()).isEqualTo(200);
+ }
+
+ @Test
+ void testDeleteStaffImage() {
+ wireMockServer.stubFor(delete(urlEqualTo("/v1/staff/456/images"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"resourceId\":456}")));
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+ Response response = api.delete("staff", 456L);
+
+ assertThat(response.status()).isEqualTo(200);
+ }
+
+ @Test
+ void testRetrieveImageWithPngFormat() throws IOException {
+ byte[] pngImage = new byte[] { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A };
+ wireMockServer.stubFor(get(urlEqualTo("/v1/clients/123/images"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/png").withBody(pngImage)));
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+ Response response = api.get("clients", 123L, new HashMap<>());
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers().get("Content-Type")).contains("image/png");
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(pngImage);
+ }
+
+ @Test
+ void testMultipleEntityTypes() {
+ String[] entityTypes = { "clients", "staff" };
+ Long[] entityIds = { 100L, 200L };
+
+ for (int i = 0; i < entityTypes.length; i++) {
+ String entityType = entityTypes[i];
+ Long entityId = entityIds[i];
+
+ wireMockServer.stubFor(get(urlEqualTo("/v1/" + entityType + "/" + entityId + "/images"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "image/jpeg").withBody(new byte[] { 0x01 })));
+ }
+
+ ImagesApi api = config.createClient(ImagesApi.class);
+
+ for (int i = 0; i < entityTypes.length; i++) {
+ Response response = api.get(entityTypes[i], entityIds[i], new HashMap<>());
+ assertThat(response.status()).isEqualTo(200);
+ }
+ }
+
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/services/RunReportsApiIntegrationTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/services/RunReportsApiIntegrationTest.java
new file mode 100644
index 0000000000..528cc7600c
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/services/RunReportsApiIntegrationTest.java
@@ -0,0 +1,166 @@
+/**
+ * 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.client.services;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import feign.Response;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.apache.fineract.client.feign.services.RunReportsApi;
+import org.apache.fineract.client.models.RunReportsResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("integration")
+class RunReportsApiIntegrationTest {
+
+ private WireMockServer wireMockServer;
+ private FineractFeignClientConfig config;
+
+ @BeforeEach
+ void setUp() {
+ wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ wireMockServer.start();
+ config = FineractFeignClientConfig.builder().baseUrl("http://localhost:" + wireMockServer.port()).credentials("test", "test")
+ .connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (wireMockServer != null) {
+ wireMockServer.stop();
+ }
+ }
+
+ @Test
+ void testRunReportGetDataWithMultipleParameters() {
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/DateRangeReport")).withQueryParam("officeId", equalTo("1"))
+ .withQueryParam("fromDate", equalTo("2024-01-01")).withQueryParam("toDate", equalTo("2024-12-31")).willReturn(aResponse()
+ .withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"columnHeaders\":[],\"data\":[]}")));
+
+ RunReportsApi api = config.createClient(RunReportsApi.class);
+ Map<String, String> params = new HashMap<>();
+ params.put("officeId", "1");
+ params.put("fromDate", "2024-01-01");
+ params.put("toDate", "2024-12-31");
+
+ RunReportsResponse response = api.runReportGetData("DateRangeReport", params);
+
+ assertThat(response).isNotNull();
+ }
+
+ @Test
+ void testRunReportGetDataWithEmptyResult() {
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/EmptyReport")).withQueryParam("officeId", equalTo("1")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"columnHeaders\":[],\"data\":[]}")));
+
+ RunReportsApi api = config.createClient(RunReportsApi.class);
+ Map<String, String> params = new HashMap<>();
+ params.put("officeId", "1");
+
+ RunReportsResponse response = api.runReportGetData("EmptyReport", params);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getColumnHeaders()).isEmpty();
+ assertThat(response.getData()).isEmpty();
+ }
+
+ @Test
+ void testRunReportGetFileAsPdf() throws IOException {
+ byte[] pdfContent = "%PDF-1.4 test content".getBytes(StandardCharsets.UTF_8);
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/LoanSummary")).withQueryParam("output-type", equalTo("PDF"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/pdf")
+ .withHeader("Content-Disposition", "attachment; filename=\"report.pdf\"").withBody(pdfContent)));
+
+ RunReportsApi api = config.createClient(RunReportsApi.class);
+ Map<String, String> params = new HashMap<>();
+ params.put("output-type", "PDF");
+
+ Response response = api.runReportGetFile("LoanSummary", params);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers()).containsKey("Content-Type");
+ assertThat(response.headers().get("Content-Type")).contains("application/pdf");
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(pdfContent);
+ }
+
+ @Test
+ void testRunReportGetFileAsCsv() throws IOException {
+ String csvContent = "id,name,balance\n1,Client1,1000.00\n2,Client2,2000.00";
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/ClientBalance")).withQueryParam("exportCSV", equalTo("true"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "text/csv")
+ .withHeader("Content-Disposition", "attachment; filename=\"report.csv\"").withBody(csvContent)));
+
+ RunReportsApi api = config.createClient(RunReportsApi.class);
+ Map<String, String> params = new HashMap<>();
+ params.put("exportCSV", "true");
+
+ Response response = api.runReportGetFile("ClientBalance", params);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers()).containsKey("Content-Type");
+ assertThat(response.headers().get("Content-Type")).contains("text/csv");
+ String content = new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ assertThat(content).isEqualTo(csvContent);
+ }
+
+ @Test
+ void testRunReportGetFileAsExcel() throws IOException {
+ byte[] excelContent = "fake excel data".getBytes(StandardCharsets.UTF_8);
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/FinancialReport")).withQueryParam("output-type", equalTo("XLS"))
+ .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/vnd.ms-excel")
+ .withHeader("Content-Disposition", "attachment; filename=\"report.xls\"").withBody(excelContent)));
+
+ RunReportsApi api = config.createClient(RunReportsApi.class);
+ Map<String, String> params = new HashMap<>();
+ params.put("output-type", "XLS");
+
+ Response response = api.runReportGetFile("FinancialReport", params);
+
+ assertThat(response.status()).isEqualTo(200);
+ assertThat(response.headers()).containsKey("Content-Type");
+ assertThat(response.body().asInputStream().readAllBytes()).isEqualTo(excelContent);
+ }
+
+ @Test
+ void testRunReportGetDataWithNoParameters() {
+ wireMockServer.stubFor(get(urlPathEqualTo("/v1/runreports/AllClients")).willReturn(
+ aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("{\"columnHeaders\":[],\"data\":[]}")));
+
+ RunReportsApi api = config.createClient(RunReportsApi.class);
+
+ RunReportsResponse response = api.runReportGetData("AllClients", new HashMap<>());
+
+ assertThat(response).isNotNull();
+ }
+
+}
diff --git a/fineract-client-feign/src/test/java/org/apache/fineract/client/util/FeignPartsTest.java b/fineract-client-feign/src/test/java/org/apache/fineract/client/util/FeignPartsTest.java
new file mode 100644
index 0000000000..c095cb9fe5
--- /dev/null
+++ b/fineract-client-feign/src/test/java/org/apache/fineract/client/util/FeignPartsTest.java
@@ -0,0 +1,296 @@
+/**
+ * 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.client.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import feign.Request;
+import feign.Response;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class FeignPartsTest {
+
+ @TempDir
+ File tempDir;
+
+ @Test
+ void testMediaTypeForJpeg() {
+ String mediaType = FeignParts.mediaType("photo.jpg");
+
+ assertThat(mediaType).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ void testMediaTypeForJpegUppercase() {
+ String mediaType = FeignParts.mediaType("photo.JPEG");
+
+ assertThat(mediaType).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ void testMediaTypeForPng() {
+ String mediaType = FeignParts.mediaType("image.png");
+
+ assertThat(mediaType).isEqualTo("image/png");
+ }
+
+ @Test
+ void testMediaTypeForPdf() {
+ String mediaType = FeignParts.mediaType("document.pdf");
+
+ assertThat(mediaType).isEqualTo("application/pdf");
+ }
+
+ @Test
+ void testMediaTypeForDocx() {
+ String mediaType = FeignParts.mediaType("report.docx");
+
+ assertThat(mediaType).isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ }
+
+ @Test
+ void testMediaTypeForDoc() {
+ String mediaType = FeignParts.mediaType("report.doc");
+
+ assertThat(mediaType).isEqualTo("application/msword");
+ }
+
+ @Test
+ void testMediaTypeForXlsx() {
+ String mediaType = FeignParts.mediaType("spreadsheet.xlsx");
+
+ assertThat(mediaType).isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ }
+
+ @Test
+ void testMediaTypeForXls() {
+ String mediaType = FeignParts.mediaType("spreadsheet.xls");
+
+ assertThat(mediaType).isEqualTo("application/vnd.ms-excel");
+ }
+
+ @Test
+ void testMediaTypeForTiff() {
+ String mediaType = FeignParts.mediaType("scan.tiff");
+
+ assertThat(mediaType).isEqualTo("image/tiff");
+ }
+
+ @Test
+ void testMediaTypeForGif() {
+ String mediaType = FeignParts.mediaType("animation.gif");
+
+ assertThat(mediaType).isEqualTo("image/gif");
+ }
+
+ @Test
+ void testMediaTypeForOdt() {
+ String mediaType = FeignParts.mediaType("document.odt");
+
+ assertThat(mediaType).isEqualTo("application/vnd.oasis.opendocument.text");
+ }
+
+ @Test
+ void testMediaTypeForOds() {
+ String mediaType = FeignParts.mediaType("spreadsheet.ods");
+
+ assertThat(mediaType).isEqualTo("application/vnd.oasis.opendocument.spreadsheet");
+ }
+
+ @Test
+ void testMediaTypeForTxt() {
+ String mediaType = FeignParts.mediaType("readme.txt");
+
+ assertThat(mediaType).isEqualTo("text/plain");
+ }
+
+ @Test
+ void testMediaTypeForUnknownExtension() {
+ String mediaType = FeignParts.mediaType("file.xyz");
+
+ assertThat(mediaType).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void testMediaTypeCaseInsensitive() {
+ assertThat(FeignParts.mediaType("FILE.JPG")).isEqualTo("image/jpeg");
+ assertThat(FeignParts.mediaType("file.PDF")).isEqualTo("application/pdf");
+ assertThat(FeignParts.mediaType("Document.DOCX"))
+ .isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ }
+
+ @Test
+ void testMediaTypeForNullFilename() {
+ String mediaType = FeignParts.mediaType(null);
+
+ assertThat(mediaType).isNull();
+ }
+
+ @Test
+ void testMediaTypeForFilenameWithoutExtension() {
+ String mediaType = FeignParts.mediaType("filename");
+
+ assertThat(mediaType).isNull();
+ }
+
+ @Test
+ void testFileNameExtractionFromContentDisposition() {
+ Response response = Response.builder().status(200)
+ .request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
+ .headers(Map.of("Content-Disposition", List.of("attachment; filename=\"test.pdf\""))).body(new byte[0]).build();
+
+ Optional<String> filename = FeignParts.fileName(response);
+
+ assertThat(filename).isPresent();
+ assertThat(filename.get()).isEqualTo("test.pdf");
+ }
+
+ @Test
+ void testFileNameExtractionWithComplexFilename() {
+ Response response = Response.builder().status(200)
+ .request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
+ .headers(Map.of("Content-Disposition", List.of("attachment; filename=\"report-2024-01-15.xlsx\""))).body(new byte[0])
+ .build();
+
+ Optional<String> filename = FeignParts.fileName(response);
+
+ assertThat(filename).isPresent();
+ assertThat(filename.get()).isEqualTo("report-2024-01-15.xlsx");
+ }
+
+ @Test
+ void testFileNameWithoutContentDisposition() {
+ Response response = Response.builder().status(200)
+ .request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null)).headers(Map.of()).body(new byte[0])
+ .build();
+
+ Optional<String> filename = FeignParts.fileName(response);
+
+ assertThat(filename).isEmpty();
+ }
+
+ @Test
+ void testFileNameWithNullHeaders() {
+ Response response = Response.builder().status(200)
+ .request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null)).headers(null).body(new byte[0])
+ .build();
+
+ Optional<String> filename = FeignParts.fileName(response);
+
+ assertThat(filename).isEmpty();
+ }
+
+ @Test
+ void testFileNameWithEmptyContentDisposition() {
+ Response response = Response.builder().status(200)
+ .request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
+ .headers(Map.of("Content-Disposition", Collections.emptyList())).body(new byte[0]).build();
+
+ Optional<String> filename = FeignParts.fileName(response);
+
+ assertThat(filename).isEmpty();
+ }
+
+ @Test
+ void testFileNameWithInvalidContentDisposition() {
+ Response response = Response.builder().status(200)
+ .request(Request.create(Request.HttpMethod.GET, "/test", Map.of(), null, null, null))
+ .headers(Map.of("Content-Disposition", List.of("inline"))).body(new byte[0]).build();
+
+ Optional<String> filename = FeignParts.fileName(response);
+
+ assertThat(filename).isEmpty();
+ }
+
+ @Test
+ void testProbeContentTypeForJpegFile() throws IOException {
+ File jpegFile = new File(tempDir, "test.jpg");
+ Files.write(jpegFile.toPath(), new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF });
+
+ String contentType = FeignParts.probeContentType(jpegFile);
+
+ assertThat(contentType).isIn("image/jpeg", "image/jpg");
+ }
+
+ @Test
+ void testProbeContentTypeForPdfFile() throws IOException {
+ File pdfFile = new File(tempDir, "test.pdf");
+ Files.write(pdfFile.toPath(), "%PDF-1.4".getBytes(StandardCharsets.UTF_8));
+
+ String contentType = FeignParts.probeContentType(pdfFile);
+
+ assertThat(contentType).isEqualTo("application/pdf");
+ }
+
+ @Test
+ void testProbeContentTypeFallsBackToMediaType() throws IOException {
+ File unknownFile = new File(tempDir, "test.docx");
+ Files.write(unknownFile.toPath(), "test content".getBytes(StandardCharsets.UTF_8));
+
+ String contentType = FeignParts.probeContentType(unknownFile);
+
+ assertThat(contentType).isNotNull();
+ assertThat(contentType).isNotEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void testProbeContentTypeForUnknownFile() throws IOException {
+ File unknownFile = new File(tempDir, "test.xyz");
+ Files.write(unknownFile.toPath(), "unknown content".getBytes(StandardCharsets.UTF_8));
+
+ String contentType = FeignParts.probeContentType(unknownFile);
+
+ assertThat(contentType).isNotNull();
+ }
+
+ @Test
+ void testMediaTypeForAllSupportedImageFormats() {
+ assertThat(FeignParts.mediaType("image.jpg")).isEqualTo("image/jpeg");
+ assertThat(FeignParts.mediaType("image.jpeg")).isEqualTo("image/jpeg");
+ assertThat(FeignParts.mediaType("image.png")).isEqualTo("image/png");
+ assertThat(FeignParts.mediaType("image.gif")).isEqualTo("image/gif");
+ assertThat(FeignParts.mediaType("image.tif")).isEqualTo("image/tiff");
+ assertThat(FeignParts.mediaType("image.tiff")).isEqualTo("image/tiff");
+ }
+
+ @Test
+ void testMediaTypeForAllSupportedDocumentFormats() {
+ assertThat(FeignParts.mediaType("doc.pdf")).isEqualTo("application/pdf");
+ assertThat(FeignParts.mediaType("doc.doc")).isEqualTo("application/msword");
+ assertThat(FeignParts.mediaType("doc.docx")).isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ assertThat(FeignParts.mediaType("doc.odt")).isEqualTo("application/vnd.oasis.opendocument.text");
+ assertThat(FeignParts.mediaType("doc.txt")).isEqualTo("text/plain");
+ }
+
+ @Test
+ void testMediaTypeForAllSupportedSpreadsheetFormats() {
+ assertThat(FeignParts.mediaType("sheet.xls")).isEqualTo("application/vnd.ms-excel");
+ assertThat(FeignParts.mediaType("sheet.xlsx")).isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ assertThat(FeignParts.mediaType("sheet.ods")).isEqualTo("application/vnd.oasis.opendocument.spreadsheet");
+ }
+}
diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/domain/BasicPasswordEncodablePlatformUser.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/domain/BasicPasswordEncodablePlatformUser.java
index 943703ead3..590942545d 100644
--- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/domain/BasicPasswordEncodablePlatformUser.java
+++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/domain/BasicPasswordEncodablePlatformUser.java
@@ -32,11 +32,19 @@ public class BasicPasswordEncodablePlatformUser implements PlatformUser {
@Getter
private Long id;
- @Getter(onMethod_ = @Override)
private String username;
- @Getter(onMethod_ = @Override)
private String password;
+ @Override
+ public String getUsername() {
+ return username;
+ }
+
+ @Override
+ public String getPassword() {
+ return password;
+ }
+
@Override
public Collection<GrantedAuthority> getAuthorities() {
return null;
diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle
index 1861b2ed12..3aed9915fa 100644
--- a/integration-tests/build.gradle
+++ b/integration-tests/build.gradle
@@ -88,15 +88,69 @@ cargo {
cargoRunLocal.dependsOn ':fineract-war:war'
cargoStartLocal.dependsOn ':fineract-war:war'
-cargoStartLocal.mustRunAfter 'testClasses'
-if (!project.hasProperty('cargoDisabled')) {
- test {
- dependsOn(cargoStartLocal)
+import java.net.HttpURLConnection
+import java.net.URL
+import javax.net.ssl.HttpsURLConnection
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManager
+import javax.net.ssl.X509TrustManager
+
+tasks.register('waitForFineract') {
+ doLast {
+ int timeoutSeconds = (project.findProperty('waitForFineractTimeoutSeconds') ?: '600') as int
+ int waited = 0
+ int interval = 5
+
+ TrustManager[] trustAllCerts = [
+ new X509TrustManager() {
+ java.security.cert.X509Certificate[] getAcceptedIssuers() { return null }
+ void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {}
+ void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {}
+ }
+ ] as TrustManager[]
+
+ SSLContext sc = SSLContext.getInstance("SSL")
+ sc.init(null, trustAllCerts, new java.security.SecureRandom())
+ HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory())
+ HttpsURLConnection.setDefaultHostnameVerifier({ hostname, session -> true })
+
+ URL url = new URL("https://localhost:8443/fineract-provider/actuator/health")
+ println "Waiting for Fineract startup (timeout: ${timeoutSeconds}s)..."
+
+ while (waited < timeoutSeconds) {
+ try {
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection()
+ connection.setConnectTimeout(2000)
+ connection.setReadTimeout(2000)
+ connection.setRequestMethod("GET")
+ int responseCode = connection.getResponseCode()
+ if (responseCode == 200) {
+ println "Fineract is up!"
+ return
+ }
+ } catch (Exception ignored) { }
+ sleep(interval * 1000)
+ waited += interval
+ println "Still waiting..."
+ }
+
+ throw new GradleException("Fineract did not start within ${timeoutSeconds} seconds")
+ }
+}
+
+tasks.named('test').configure {
+ if (!project.hasProperty('cargoDisabled')) {
+ dependsOn cargoStartLocal, waitForFineract
finalizedBy cargoStopLocal
}
}
+if (!project.hasProperty('cargoDisabled')) {
+ cargoStartLocal.mustRunAfter testClasses
+ waitForFineract.mustRunAfter cargoStartLocal
+}
+
// Configure proper test output directories
sourceSets {
test {
diff --git a/integration-tests/dependencies.gradle b/integration-tests/dependencies.gradle
index 66f45bb744..9b0cb8cafe 100644
--- a/integration-tests/dependencies.gradle
+++ b/integration-tests/dependencies.gradle
@@ -21,7 +21,8 @@ dependencies {
// Do NOT repeat dependencies which are ALREADY in implementation or runtimeOnly!
//
tomcat 'org.apache.tomcat:tomcat:10.1.42@zip'
- testImplementation( files("$rootDir/fineract-provider/build/classes/java/main/"),
+ def providerMainOutput = project(':fineract-provider').extensions.getByType(SourceSetContainer).named('main').get().output
+ testImplementation( providerMainOutput,
project(path: ':fineract-core', configuration: 'runtimeElements'),
project(path: ':fineract-accounting', configuration: 'runtimeElements'),
project(path: ':fineract-investor', configuration: 'runtimeElements'),
@@ -32,9 +33,17 @@ dependencies {
project(path: ':fineract-savings', configuration: 'runtimeElements'),
project(path: ':fineract-provider', configuration: 'runtimeElements'),
project(path: ':fineract-avro-schemas', configuration: 'runtimeElements'),
- project(path: ':fineract-client', configuration: 'runtimeElements'),
project(path: ':fineract-progressive-loan', configuration: 'runtimeElements')
)
+
+ // Retrofit client for existing tests
+ testImplementation project(path: ':fineract-client', configuration: 'runtimeElements')
+
+ // Feign client - exclude fineract-client module to avoid Retrofit/Feign model conflicts
+ // Both generated and hand-written Feign services are available in feign.services package
+ testImplementation(project(path: ':fineract-client-feign', configuration: 'runtimeElements')) {
+ exclude group: 'org.apache.fineract', module: 'fineract-client'
+ }
testImplementation ('org.mock-server:mockserver-junit-jupiter') {
exclude group: 'com.sun.mail', module: 'mailapi'
exclude group: 'javax.servlet', module: 'javax.servlet-api'
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignClientSmokeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignClientSmokeTest.java
new file mode 100644
index 0000000000..597f05d654
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignClientSmokeTest.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.integrationtests.client;
+
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.feign.FineractFeignClientConfig;
+import org.apache.fineract.integrationtests.ConfigProperties;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Simple smoke tests for Feign-based Fineract client
+ *
+ */
+public class FeignClientSmokeTest extends FeignIntegrationTest {
+
+ private String getTestUrl() {
+ return System.getProperty("fineract.it.url", ConfigProperties.Backend.PROTOCOL + "://" + ConfigProperties.Backend.HOST + ":"
+ + ConfigProperties.Backend.PORT + "/fineract-provider/api");
+ }
+
+ @Test
+ @Order(1)
+ public void testFeignClientBuilder() {
+ FineractFeignClient.Builder builder = FineractFeignClient.builder().baseUrl(getTestUrl())
+ .credentials(ConfigProperties.Backend.USERNAME, ConfigProperties.Backend.PASSWORD);
+
+ assertThat(builder).isNotNull();
+ }
+
+ @Test
+ @Order(2)
+ public void testFeignClientConfig() {
+ FineractFeignClientConfig.Builder configBuilder = FineractFeignClientConfig.builder().baseUrl(getTestUrl())
+ .credentials(ConfigProperties.Backend.USERNAME, ConfigProperties.Backend.PASSWORD).debugEnabled(true);
+
+ FineractFeignClientConfig config = configBuilder.build();
+
+ assertThat(config).isNotNull();
+ }
+
+ @Test
+ @Order(3)
+ public void testFeignClientCanInstantiateApis() {
+ assertThat(fineractClient()).isNotNull();
+ assertThat(fineractClient().offices()).isNotNull();
+ assertThat(fineractClient().clients()).isNotNull();
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignDocumentTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignDocumentTest.java
new file mode 100644
index 0000000000..77241fd7a1
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignDocumentTest.java
@@ -0,0 +1,150 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import feign.Response;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import org.apache.fineract.client.feign.FeignException;
+import org.apache.fineract.client.feign.FineractMultipartEncoder;
+import org.apache.fineract.client.models.DocumentData;
+import org.apache.fineract.client.models.PostEntityTypeEntityIdDocumentsResponse;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class FeignDocumentTest extends FeignIntegrationTest {
+
+ final File testFile = new File(getClass().getResource("/michael.vorburger-crepes.jpg").getFile());
+
+ Long clientId;
+ Long documentId;
+
+ @Test
+ @Order(1)
+ void setupClient() {
+ FeignClientHelper clientHelper = new FeignClientHelper(fineractClient());
+ clientId = clientHelper.createClient("Feign", "Test");
+ assertThat(clientId).isNotNull();
+ }
+
+ @Test
+ @Order(2)
+ void testCreateDocument() throws IOException {
+ String name = "Feign Test Document";
+ String description = "Testing DocumentsApiFixed with Feign client";
+
+ byte[] fileData = Files.readAllBytes(testFile.toPath());
+ FineractMultipartEncoder.MultipartData multipartData = new FineractMultipartEncoder.MultipartData()
+ .addFile("file", testFile.getName(), fileData, "image/jpeg").addText("name", name).addText("description", description);
+
+ PostEntityTypeEntityIdDocumentsResponse response = ok(
+ () -> fineractClient().documentsFixed().createDocument("clients", clientId, multipartData));
+
+ assertThat(response).isNotNull();
+ assertThat(response.getResourceId()).isNotNull();
+ documentId = response.getResourceId();
+ }
+
+ @Test
+ @Order(3)
+ void testRetrieveAllDocuments() {
+ var documents = ok(() -> fineractClient().documentsFixed().retrieveAllDocuments("clients", clientId));
+
+ assertThat(documents).isNotNull();
+ assertThat(documents).isNotEmpty();
+ }
+
+ @Test
+ @Order(4)
+ void testGetDocument() {
+ DocumentData doc = ok(() -> fineractClient().documentsFixed().getDocument("clients", clientId, documentId));
+
+ assertThat(doc).isNotNull();
+ assertThat(doc.getName()).isEqualTo("Feign Test Document");
+ assertThat(doc.getFileName()).isEqualTo(testFile.getName());
+ assertThat(doc.getDescription()).isEqualTo("Testing DocumentsApiFixed with Feign client");
+ assertThat(doc.getId()).isEqualTo(documentId);
+ assertThat(doc.getParentEntityType()).isEqualTo("clients");
+ assertThat(doc.getParentEntityId()).isEqualTo(clientId);
+ assertThat(doc.getType()).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ @Order(5)
+ void testDownloadFile() throws IOException {
+ Response response = fineractClient().documentsFixed().downloadFile("clients", clientId, documentId);
+
+ assertNotNull(response);
+ assertEquals(200, response.status());
+
+ try (InputStream inputStream = response.body().asInputStream()) {
+ byte[] bytes = inputStream.readAllBytes();
+ assertThat(bytes.length).isEqualTo((int) testFile.length());
+ }
+ }
+
+ @Test
+ @Order(6)
+ void testUpdateDocument() {
+ String newName = "Updated Feign Test";
+ String newDescription = "Updated via Feign client";
+
+ FineractMultipartEncoder.MultipartData multipartData = new FineractMultipartEncoder.MultipartData().addText("name", newName)
+ .addText("description", newDescription);
+
+ var updateResponse = ok(() -> fineractClient().documentsFixed().updateDocument("clients", clientId, documentId, multipartData));
+
+ assertThat(updateResponse).isNotNull();
+
+ DocumentData doc = ok(() -> fineractClient().documentsFixed().getDocument("clients", clientId, documentId));
+ assertThat(doc.getName()).isEqualTo(newName);
+ assertThat(doc.getDescription()).isEqualTo(newDescription);
+ }
+
+ @Test
+ @Order(99)
+ void testDeleteDocument() {
+ var deleteResponse = ok(() -> fineractClient().documentsFixed().deleteDocument("clients", clientId, documentId));
+ assertThat(deleteResponse).isNotNull();
+
+ FeignException exception = assertThrows(FeignException.class,
+ () -> fineractClient().documentsFixed().getDocument("clients", clientId, documentId));
+ assertEquals(404, exception.status());
+ }
+
+ @Test
+ @Order(9999)
+ void testCreateDocumentBadArgs() {
+ FineractMultipartEncoder.MultipartData multipartData = new FineractMultipartEncoder.MultipartData().addText("name", "test.pdf");
+
+ FeignException exception = assertThrows(FeignException.class,
+ () -> fineractClient().documentsFixed().createDocument("clients", clientId, multipartData));
+ assertEquals(400, exception.status());
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignImageTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignImageTest.java
new file mode 100644
index 0000000000..22c7c22a0f
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignImageTest.java
@@ -0,0 +1,111 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import feign.Response;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.HashMap;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.CreateStaffResponse;
+import org.apache.fineract.client.models.StaffRequest;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class FeignImageTest extends FeignIntegrationTest {
+
+ final File testImage = new File(getClass().getResource("/michael.vorburger-crepes.jpg").getFile());
+
+ Long staffId;
+
+ @Override
+ protected FineractFeignClient fineractClient() {
+ return org.apache.fineract.integrationtests.common.FineractFeignClientHelper.createNewFineractFeignClient("mifos", "password",
+ true);
+ }
+
+ @Test
+ @Order(1)
+ void setupStaff() {
+ StaffRequest request = new StaffRequest();
+ request.setOfficeId(1L);
+ request.setFirstname("Feign");
+ request.setLastname("ImageTest" + System.currentTimeMillis());
+ request.setJoiningDate(LocalDate.now(ZoneId.of("UTC")).toString());
+ request.setDateFormat("yyyy-MM-dd");
+ request.setLocale("en_US");
+
+ CreateStaffResponse response = ok(() -> fineractClient().staff().create3(request));
+ assertThat(response).isNotNull();
+ assertThat(response.getResourceId()).isNotNull();
+ staffId = response.getResourceId();
+ }
+
+ @Test
+ @Order(2)
+ void testCreateStaffImage() throws Exception {
+ String dataUrl = org.apache.fineract.client.feign.services.ImagesApi.prepareFileUpload(testImage);
+ Response response = fineractClient().images().create("staff", staffId, dataUrl);
+
+ assertNotNull(response);
+ assertEquals(200, response.status());
+ }
+
+ @Test
+ @Order(3)
+ void testRetrieveStaffImage() throws IOException {
+ Response response = fineractClient().images().get("staff", staffId, new HashMap<>());
+
+ assertNotNull(response);
+ assertEquals(200, response.status());
+
+ try (InputStream inputStream = response.body().asInputStream()) {
+ byte[] bytes = inputStream.readAllBytes();
+ assertThat(bytes.length).isGreaterThan(0);
+ }
+ }
+
+ @Test
+ @Order(4)
+ void testUpdateStaffImage() {
+ Response response = fineractClient().images().update("staff", staffId,
+ org.apache.fineract.client.feign.services.ImagesApi.prepareFileUpload(testImage));
+
+ assertNotNull(response);
+ assertEquals(200, response.status());
+ }
+
+ @Test
+ @Order(99)
+ void testDeleteStaffImage() {
+ Response response = fineractClient().images().delete("staff", staffId);
+
+ assertNotNull(response);
+ assertEquals(200, response.status());
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignIntegrationTest.java
new file mode 100644
index 0000000000..593601a0d2
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignIntegrationTest.java
@@ -0,0 +1,103 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client;
+
+import java.math.BigDecimal;
+import java.util.Optional;
+import java.util.function.Supplier;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.feign.util.FeignCalls;
+import org.apache.fineract.integrationtests.common.FineractFeignClientHelper;
+import org.assertj.core.api.AbstractBigDecimalAssert;
+import org.assertj.core.api.AbstractBooleanAssert;
+import org.assertj.core.api.AbstractDoubleAssert;
+import org.assertj.core.api.AbstractFloatAssert;
+import org.assertj.core.api.AbstractIntegerAssert;
+import org.assertj.core.api.AbstractLongAssert;
+import org.assertj.core.api.AbstractStringAssert;
+import org.assertj.core.api.Assertions;
+import org.assertj.core.api.IterableAssert;
+import org.assertj.core.api.ObjectAssert;
+import org.assertj.core.api.OptionalAssert;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.TestMethodOrder;
+
+/**
+ * Base Integration Test class for Feign-based client
+ *
+ * @author Apache Fineract
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public abstract class FeignIntegrationTest {
+
+ protected FineractFeignClient fineractClient() {
+ return FineractFeignClientHelper.getFineractFeignClient();
+ }
+
+ protected <T> T ok(Supplier<T> call) {
+ return FeignCalls.ok(call);
+ }
+
+ protected void executeVoid(Runnable call) {
+ FeignCalls.executeVoid(call);
+ }
+
+ public static IterableAssert<?> assertThat(Iterable<?> actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractBigDecimalAssert<?> assertThat(BigDecimal actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static <T> ObjectAssert<T> assertThat(T actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractLongAssert<?> assertThat(Long actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractDoubleAssert<?> assertThat(Double actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractFloatAssert<?> assertThat(Float actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractIntegerAssert<?> assertThat(Integer actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractBooleanAssert<?> assertThat(Boolean actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static AbstractStringAssert<?> assertThat(String actual) {
+ return Assertions.assertThat(actual);
+ }
+
+ public static <T> OptionalAssert<T> assertThat(Optional<T> actual) {
+ return Assertions.assertThat(actual);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java
new file mode 100644
index 0000000000..0a4155ca8a
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java
@@ -0,0 +1,219 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign;
+
+import java.time.LocalDate;
+import java.util.function.Function;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.GetLoansLoanIdStatus;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
+import org.apache.fineract.client.models.PostLoansRequest;
+import org.apache.fineract.integrationtests.client.FeignIntegrationTest;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignBusinessDateHelper;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignClientHelper;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignJournalEntryHelper;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignLoanHelper;
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignTransactionHelper;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanProductTemplates;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanRequestBuilders;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestAccounts;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestValidators;
+import org.apache.fineract.integrationtests.common.FineractFeignClientHelper;
+import org.junit.jupiter.api.BeforeAll;
+
+public abstract class FeignLoanTestBase extends FeignIntegrationTest implements LoanProductTemplates {
+
+ protected static FeignAccountHelper accountHelper;
+ protected static FeignLoanHelper loanHelper;
+ protected static FeignTransactionHelper transactionHelper;
+ protected static FeignJournalEntryHelper journalHelper;
+ protected static FeignBusinessDateHelper businessDateHelper;
+ protected static FeignClientHelper clientHelper;
+ protected static LoanTestAccounts accounts;
+
+ @BeforeAll
+ public static void setupHelpers() {
+ FineractFeignClient client = FineractFeignClientHelper.getFineractFeignClient();
+ accountHelper = new FeignAccountHelper(client);
+ loanHelper = new FeignLoanHelper(client);
+ transactionHelper = new FeignTransactionHelper(client);
+ journalHelper = new FeignJournalEntryHelper(client);
+ businessDateHelper = new FeignBusinessDateHelper(client);
+ clientHelper = new FeignClientHelper(client);
+ }
+
+ protected LoanTestAccounts getAccounts() {
+ if (accounts == null) {
+ accounts = new LoanTestAccounts(accountHelper);
+ }
+ return accounts;
+ }
+
+ @Override
+ public Long getAssetAccountId(String accountName) {
+ return getAccounts().getAssetAccountId(accountName);
+ }
+
+ @Override
+ public Long getLiabilityAccountId(String accountName) {
+ return getAccounts().getLiabilityAccountId(accountName);
+ }
+
+ @Override
+ public Long getIncomeAccountId(String accountName) {
+ return getAccounts().getIncomeAccountId(accountName);
+ }
+
+ @Override
+ public Long getExpenseAccountId(String accountName) {
+ return getAccounts().getExpenseAccountId(accountName);
+ }
+
+ protected Long createClient(String firstName, String lastName) {
+ return clientHelper.createClient(firstName, lastName);
+ }
+
+ protected Long createLoanProduct(PostLoanProductsRequest request) {
+ return loanHelper.createLoanProduct(request);
+ }
+
+ protected Long applyForLoan(PostLoansRequest request) {
+ return loanHelper.applyForLoan(request);
+ }
+
+ protected Long approveLoan(Long loanId, PostLoansLoanIdRequest request) {
+ return loanHelper.approveLoan(loanId, request);
+ }
+
+ protected Long disburseLoan(Long loanId, PostLoansLoanIdRequest request) {
+ return loanHelper.disburseLoan(loanId, request);
+ }
+
+ protected GetLoansLoanIdResponse getLoanDetails(Long loanId) {
+ return loanHelper.getLoanDetails(loanId);
+ }
+
+ protected void undoApproval(Long loanId) {
+ loanHelper.undoApproval(loanId);
+ }
+
+ protected void undoDisbursement(Long loanId) {
+ loanHelper.undoDisbursement(loanId);
+ }
+
+ protected Long addRepayment(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ return transactionHelper.addRepayment(loanId, request);
+ }
+
+ protected Long addInterestWaiver(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ return transactionHelper.addInterestWaiver(loanId, request);
+ }
+
+ protected Long chargeOff(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ return transactionHelper.chargeOff(loanId, request);
+ }
+
+ protected Long addChargeback(Long loanId, Long transactionId, PostLoansLoanIdTransactionsRequest request) {
+ return transactionHelper.addChargeback(loanId, transactionId, request);
+ }
+
+ protected void undoRepayment(Long loanId, Long transactionId, String transactionDate) {
+ transactionHelper.undoRepayment(loanId, transactionId, transactionDate);
+ }
+
+ protected void verifyJournalEntries(Long loanId, LoanTestData.Journal... expectedEntries) {
+ journalHelper.verifyJournalEntries(loanId, expectedEntries);
+ }
+
+ protected void verifyJournalEntriesSequentially(Long loanId, LoanTestData.Journal... expectedEntries) {
+ journalHelper.verifyJournalEntriesSequentially(loanId, expectedEntries);
+ }
+
+ protected void runAt(String date, Runnable action) {
+ businessDateHelper.runAt(date, action);
+ }
+
+ protected void updateBusinessDate(String type, String date) {
+ businessDateHelper.updateBusinessDate(type, date);
+ }
+
+ protected void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue,
+ double principalPaid, double principalOutstanding) {
+ LoanTestValidators.validateRepaymentPeriod(loanDetails, index, dueDate, principalDue, principalPaid, principalOutstanding, 0.0,
+ 0.0);
+ }
+
+ protected void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue,
+ double principalPaid, double principalOutstanding, double paidInAdvance, double paidLate) {
+ LoanTestValidators.validateRepaymentPeriod(loanDetails, index, dueDate, principalDue, principalPaid, principalOutstanding,
+ paidInAdvance, paidLate);
+ }
+
+ protected void verifyLoanStatus(GetLoansLoanIdResponse loanDetails, Function<GetLoansLoanIdStatus, Boolean> extractor) {
+ LoanTestValidators.verifyLoanStatus(loanDetails, extractor);
+ }
+
+ protected Long createApproveAndDisburseLoan(Long clientId, Long productId, String date, Double principal, Integer numberOfRepayments) {
+ PostLoansRequest applyRequest = LoanRequestBuilders.applyLoan(clientId, productId, date, principal, numberOfRepayments);
+ Long loanId = applyForLoan(applyRequest);
+
+ PostLoansLoanIdRequest approveRequest = LoanRequestBuilders.approveLoan(principal, date);
+ approveLoan(loanId, approveRequest);
+
+ PostLoansLoanIdRequest disburseRequest = LoanRequestBuilders.disburseLoan(principal, date);
+ disburseLoan(loanId, disburseRequest);
+
+ return loanId;
+ }
+
+ protected Long createApprovedLoan(Long clientId, Long productId, String date, Double principal, Integer numberOfRepayments) {
+ PostLoansRequest applyRequest = LoanRequestBuilders.applyLoan(clientId, productId, date, principal, numberOfRepayments);
+ Long loanId = applyForLoan(applyRequest);
+
+ PostLoansLoanIdRequest approveRequest = LoanRequestBuilders.approveLoan(principal, date);
+ approveLoan(loanId, approveRequest);
+
+ return loanId;
+ }
+
+ protected LoanTestData.Journal debit(Long glAccountId, double amount) {
+ return LoanTestData.Journal.debit(glAccountId, amount);
+ }
+
+ protected LoanTestData.Journal credit(Long glAccountId, double amount) {
+ return LoanTestData.Journal.credit(glAccountId, amount);
+ }
+
+ protected PostLoansLoanIdTransactionsRequest repayment(double amount, String date) {
+ return LoanRequestBuilders.repayLoan(amount, date);
+ }
+
+ protected PostLoansLoanIdTransactionsRequest waiveInterest(double amount, String date) {
+ return LoanRequestBuilders.waiveInterest(amount, date);
+ }
+
+ protected PostLoansLoanIdTransactionsRequest chargeOff(String date) {
+ return LoanRequestBuilders.chargeOff(date);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java
new file mode 100644
index 0000000000..4dc91104c1
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java
@@ -0,0 +1,95 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+
+import java.util.Collections;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.GetGLAccountsResponse;
+import org.apache.fineract.client.models.PostGLAccountsRequest;
+import org.apache.fineract.client.models.PostGLAccountsResponse;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.apache.fineract.integrationtests.common.accounting.Account;
+
+public class FeignAccountHelper {
+
+ private final FineractFeignClient fineractClient;
+
+ public FeignAccountHelper(FineractFeignClient fineractClient) {
+ this.fineractClient = fineractClient;
+ }
+
+ public Account createAssetAccount(String name) {
+ return createAccount(name, "1", "ASSET");
+ }
+
+ public Account createLiabilityAccount(String name) {
+ return createAccount(name, "2", "LIABILITY");
+ }
+
+ public Account createIncomeAccount(String name) {
+ return createAccount(name, "4", "INCOME");
+ }
+
+ public Account createExpenseAccount(String name) {
+ return createAccount(name, "5", "EXPENSE");
+ }
+
+ private Account createAccount(String name, String glCode, String type) {
+ String uniqueName = Utils.uniqueRandomStringGenerator(name + "_", 4);
+ String accountCode = Utils.uniqueRandomStringGenerator("GL_" + glCode, 6);
+
+ PostGLAccountsRequest request = new PostGLAccountsRequest()//
+ .name(uniqueName)//
+ .glCode(accountCode)//
+ .manualEntriesAllowed(true)//
+ .type(getAccountTypeId(type))//
+ .usage(1);
+
+ PostGLAccountsResponse response = ok(() -> fineractClient.generalLedgerAccount().createGLAccount1(request));
+
+ GetGLAccountsResponse account = ok(
+ () -> fineractClient.generalLedgerAccount().retreiveAccount(response.getResourceId(), Collections.emptyMap()));
+
+ return new Account(account.getId().intValue(), getAccountType(type));
+ }
+
+ private Integer getAccountTypeId(String type) {
+ return switch (type) {
+ case "ASSET" -> 1;
+ case "LIABILITY" -> 2;
+ case "EQUITY" -> 3;
+ case "INCOME" -> 4;
+ case "EXPENSE" -> 5;
+ default -> throw new IllegalArgumentException("Unknown account type: " + type);
+ };
+ }
+
+ private Account.AccountType getAccountType(String type) {
+ return switch (type) {
+ case "ASSET" -> Account.AccountType.ASSET;
+ case "LIABILITY" -> Account.AccountType.LIABILITY;
+ case "EQUITY" -> Account.AccountType.EQUITY;
+ case "INCOME" -> Account.AccountType.INCOME;
+ case "EXPENSE" -> Account.AccountType.EXPENSE;
+ default -> throw new IllegalArgumentException("Unknown account type: " + type);
+ };
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java
new file mode 100644
index 0000000000..88038af59f
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java
@@ -0,0 +1,62 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+
+import java.util.Collections;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.BusinessDateResponse;
+import org.apache.fineract.client.models.BusinessDateUpdateRequest;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
+
+public class FeignBusinessDateHelper {
+
+ private final FineractFeignClient fineractClient;
+
+ public FeignBusinessDateHelper(FineractFeignClient fineractClient) {
+ this.fineractClient = fineractClient;
+ }
+
+ public BusinessDateResponse getBusinessDate(String type) {
+ return ok(() -> fineractClient.businessDateManagement().getBusinessDate(type));
+ }
+
+ public void updateBusinessDate(String type, String date) {
+ BusinessDateUpdateRequest request = new BusinessDateUpdateRequest()//
+ .type(BusinessDateUpdateRequest.TypeEnum.fromValue(type))//
+ .date(date)//
+ .dateFormat("yyyy-MM-dd")//
+ .locale(LoanTestData.LOCALE);
+
+ ok(() -> fineractClient.businessDateManagement().updateBusinessDate(request, Collections.emptyMap()));
+ }
+
+ public void runAt(String date, Runnable action) {
+ BusinessDateResponse originalDate = getBusinessDate("BUSINESS_DATE");
+ try {
+ updateBusinessDate("BUSINESS_DATE", date);
+ action.run();
+ } finally {
+ if (originalDate != null && originalDate.getDate() != null) {
+ updateBusinessDate("BUSINESS_DATE", originalDate.getDate().toString());
+ }
+ }
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java
new file mode 100644
index 0000000000..3c2f806f4b
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.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.integrationtests.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+
+import java.util.Collections;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.GetClientsClientIdResponse;
+import org.apache.fineract.client.models.PostClientsRequest;
+import org.apache.fineract.client.models.PostClientsResponse;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
+import org.apache.fineract.integrationtests.common.Utils;
+
+public class FeignClientHelper {
+
+ private final FineractFeignClient fineractClient;
+
+ public FeignClientHelper(FineractFeignClient fineractClient) {
+ this.fineractClient = fineractClient;
+ }
+
+ public Long createClient(String firstName, String lastName) {
+ String externalId = Utils.randomStringGenerator("EXT_", 7);
+ String activationDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+
+ PostClientsRequest request = new PostClientsRequest()//
+ .officeId(1L)//
+ .legalFormId(1L)//
+ .firstname(firstName)//
+ .lastname(lastName)//
+ .externalId(externalId)//
+ .active(true)//
+ .activationDate(activationDate)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN)//
+ .locale(LoanTestData.LOCALE);
+
+ return createClient(request);
+ }
+
+ public Long createClient(PostClientsRequest request) {
+ PostClientsResponse response = ok(() -> fineractClient.clients().create6(request));
+ return response.getClientId();
+ }
+
+ public GetClientsClientIdResponse getClient(Long clientId) {
+ return ok(() -> fineractClient.clients().retrieveOne11(clientId, Collections.emptyMap()));
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java
new file mode 100644
index 0000000000..3f1416006b
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java
@@ -0,0 +1,69 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse;
+import org.apache.fineract.client.models.JournalEntryTransactionItem;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData;
+
+public class FeignJournalEntryHelper {
+
+ private final FineractFeignClient fineractClient;
+
+ public FeignJournalEntryHelper(FineractFeignClient fineractClient) {
+ this.fineractClient = fineractClient;
+ }
+
+ public GetJournalEntriesTransactionIdResponse getJournalEntriesForLoan(Long loanId) {
+ return ok(() -> fineractClient.journalEntries().retrieveAll1(Map.of("loanId", loanId)));
+ }
+
+ public void verifyJournalEntries(Long loanId, LoanTestData.Journal... expectedEntries) {
+ GetJournalEntriesTransactionIdResponse journalEntries = getJournalEntriesForLoan(loanId);
+ assertNotNull(journalEntries);
+ assertNotNull(journalEntries.getPageItems());
+
+ List<JournalEntryTransactionItem> actualEntries = journalEntries.getPageItems();
+ assertEquals(expectedEntries.length, actualEntries.size(),
+ "Expected " + expectedEntries.length + " journal entries but found " + actualEntries.size());
+
+ for (int i = 0; i < expectedEntries.length; i++) {
+ LoanTestData.Journal expected = expectedEntries[i];
+ JournalEntryTransactionItem actual = actualEntries.get(i);
+
+ Double expectedAmount = expected.amount;
+ Double actualAmount = actual.getAmount();
+ assertEquals(0, Double.compare(expectedAmount, actualAmount),
+ "Journal entry " + i + " amount mismatch: expected " + expectedAmount + " but got " + actualAmount);
+ assertEquals(expected.account.getAccountID().longValue(), actual.getGlAccountId(), "Journal entry " + i + " account mismatch");
+ assertEquals(expected.type, actual.getEntryType().getValue(), "Journal entry " + i + " type mismatch");
+ }
+ }
+
+ public void verifyJournalEntriesSequentially(Long loanId, LoanTestData.Journal... expectedEntries) {
+ verifyJournalEntries(loanId, expectedEntries);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
new file mode 100644
index 0000000000..cff401185e
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java
@@ -0,0 +1,109 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+
+import java.math.BigDecimal;
+import java.util.Map;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.client.models.PostLoanProductsResponse;
+import org.apache.fineract.client.models.PostLoansLoanIdRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostLoansRequest;
+import org.apache.fineract.client.models.PostLoansResponse;
+
+public class FeignLoanHelper {
+
+ private final FineractFeignClient fineractClient;
+
+ public FeignLoanHelper(FineractFeignClient fineractClient) {
+ this.fineractClient = fineractClient;
+ }
+
+ public Long createLoanProduct(PostLoanProductsRequest request) {
+ PostLoanProductsResponse response = ok(() -> fineractClient.loanProducts().createLoanProduct(request));
+ return response.getResourceId();
+ }
+
+ public Long applyForLoan(PostLoansRequest request) {
+ PostLoansResponse response = ok(() -> fineractClient.loans().calculateLoanScheduleOrSubmitLoanApplication(request, null));
+ return response.getLoanId();
+ }
+
+ public Long approveLoan(Long loanId, PostLoansLoanIdRequest request) {
+ PostLoansLoanIdResponse response = ok(() -> fineractClient.loans().stateTransitions(loanId, request, Map.of("command", "approve")));
+ return response.getLoanId();
+ }
+
+ public Long disburseLoan(Long loanId, PostLoansLoanIdRequest request) {
+ PostLoansLoanIdResponse response = ok(
+ () -> fineractClient.loans().stateTransitions(loanId, request, Map.of("command", "disburse")));
+ return response.getLoanId();
+ }
+
+ public GetLoansLoanIdResponse getLoanDetails(Long loanId) {
+ return ok(() -> fineractClient.loans().retrieveLoan(loanId, Map.of("associations", "all", "exclude", "guarantors,futureSchedule")));
+ }
+
+ public void undoApproval(Long loanId) {
+ PostLoansLoanIdRequest request = new PostLoansLoanIdRequest();
+ ok(() -> fineractClient.loans().stateTransitions(loanId, request, Map.of("command", "undoApproval")));
+ }
+
+ public void undoDisbursement(Long loanId) {
+ PostLoansLoanIdRequest request = new PostLoansLoanIdRequest();
+ ok(() -> fineractClient.loans().stateTransitions(loanId, request, Map.of("command", "undoDisbursal")));
+ }
+
+ public Long applyAndApproveLoan(Long clientId, Long productId, String submittedOnDate, Double principal, Integer numberOfRepayments) {
+ PostLoansRequest applyRequest = new PostLoansRequest()//
+ .clientId(clientId)//
+ .productId(productId)//
+ .loanType("individual")//
+ .submittedOnDate(submittedOnDate)//
+ .expectedDisbursementDate(submittedOnDate)//
+ .principal(BigDecimal.valueOf(principal))//
+ .loanTermFrequency(numberOfRepayments)//
+ .loanTermFrequencyType(2)//
+ .numberOfRepayments(numberOfRepayments)//
+ .repaymentEvery(1)//
+ .repaymentFrequencyType(2)//
+ .interestRatePerPeriod(BigDecimal.ZERO)//
+ .amortizationType(1)//
+ .interestType(0)//
+ .interestCalculationPeriodType(1)//
+ .transactionProcessingStrategyCode("mifos-standard-strategy")//
+ .locale("en")//
+ .dateFormat("dd MMMM yyyy");
+
+ Long loanId = applyForLoan(applyRequest);
+
+ PostLoansLoanIdRequest approveRequest = new PostLoansLoanIdRequest()//
+ .approvedLoanAmount(BigDecimal.valueOf(principal))//
+ .approvedOnDate(submittedOnDate)//
+ .locale("en")//
+ .dateFormat("dd MMMM yyyy");
+
+ approveLoan(loanId, approveRequest);
+ return loanId;
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java
new file mode 100644
index 0000000000..7356844e2b
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java
@@ -0,0 +1,75 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.helpers;
+
+import static org.apache.fineract.client.feign.util.FeignCalls.ok;
+
+import java.util.Map;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
+import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest;
+
+public class FeignTransactionHelper {
+
+ private final FineractFeignClient fineractClient;
+
+ public FeignTransactionHelper(FineractFeignClient fineractClient) {
+ this.fineractClient = fineractClient;
+ }
+
+ public Long addRepayment(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ PostLoansLoanIdTransactionsResponse response = ok(
+ () -> fineractClient.loanTransactions().executeLoanTransaction(loanId, request, Map.of("command", "repayment")));
+ return response.getResourceId();
+ }
+
+ public Long addInterestWaiver(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ PostLoansLoanIdTransactionsResponse response = ok(
+ () -> fineractClient.loanTransactions().executeLoanTransaction(loanId, request, Map.of("command", "waiveInterest")));
+ return response.getResourceId();
+ }
+
+ public Long chargeOff(Long loanId, PostLoansLoanIdTransactionsRequest request) {
+ PostLoansLoanIdTransactionsResponse response = ok(
+ () -> fineractClient.loanTransactions().executeLoanTransaction(loanId, request, Map.of("command", "chargeOff")));
+ return response.getResourceId();
+ }
+
+ public Long addChargeback(Long loanId, Long transactionId, PostLoansLoanIdTransactionsRequest request) {
+ PostLoansLoanIdTransactionsTransactionIdRequest chargebackRequest = new PostLoansLoanIdTransactionsTransactionIdRequest()//
+ .transactionDate(request.getTransactionDate())//
+ .transactionAmount(request.getTransactionAmount())//
+ .locale(request.getLocale())//
+ .dateFormat(request.getDateFormat());
+
+ PostLoansLoanIdTransactionsResponse response = ok(() -> fineractClient.loanTransactions().adjustLoanTransaction(loanId,
+ transactionId, chargebackRequest, Map.of("command", "chargeback")));
+ return response.getResourceId();
+ }
+
+ public void undoRepayment(Long loanId, Long transactionId, String transactionDate) {
+ PostLoansLoanIdTransactionsTransactionIdRequest request = new PostLoansLoanIdTransactionsTransactionIdRequest();
+ request.setTransactionDate(transactionDate);
+ request.setTransactionAmount(0.0);
+ request.setDateFormat("dd MMMM yyyy");
+ request.setLocale("en");
+ ok(() -> fineractClient.loanTransactions().adjustLoanTransaction(loanId, transactionId, request, Map.of("command", "undo")));
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java
new file mode 100644
index 0000000000..95cdedb1f9
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java
@@ -0,0 +1,240 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.modules;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import org.apache.fineract.client.models.AllowAttributeOverrides;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.AmortizationType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.DaysInMonthType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.DaysInYearType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.InterestCalculationPeriodType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.InterestRateFrequencyType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.InterestType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.RepaymentFrequencyType;
+import org.apache.fineract.integrationtests.client.feign.modules.LoanTestData.RescheduleStrategyMethod;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
+import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
+
+public interface LoanProductTemplates {
+
+ Long getAssetAccountId(String accountName);
+
+ Long getLiabilityAccountId(String accountName);
+
+ Long getIncomeAccountId(String accountName);
+
+ Long getExpenseAccountId(String accountName);
+
+ default PostLoanProductsRequest onePeriod30DaysNoInterest() {
+ return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6))//
+ .shortName(Utils.uniqueRandomStringGenerator("", 4))//
+ .description("Loan Product Description")//
+ .includeInBorrowerCycle(false)//
+ .currencyCode("USD")//
+ .digitsAfterDecimal(2)//
+ .inMultiplesOf(0)//
+ .installmentAmountInMultiplesOf(1)//
+ .useBorrowerCycle(false)//
+ .minPrincipal(100.0)//
+ .principal(1000.0)//
+ .maxPrincipal(100000.0)//
+ .minNumberOfRepayments(1)//
+ .numberOfRepayments(1)//
+ .maxNumberOfRepayments(30)//
+ .isLinkedToFloatingInterestRates(false)//
+ .minInterestRatePerPeriod(0.0)//
+ .interestRatePerPeriod(0.0)//
+ .maxInterestRatePerPeriod(100.0)//
+ .interestRateFrequencyType(InterestRateFrequencyType.MONTHS)//
+ .repaymentEvery(30)//
+ .repaymentFrequencyType(RepaymentFrequencyType.DAYS_L)//
+ .amortizationType(AmortizationType.EQUAL_INSTALLMENTS)//
+ .interestType(InterestType.DECLINING_BALANCE)//
+ .isEqualAmortization(false)//
+ .interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)//
+ .transactionProcessingStrategyCode(
+ LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY)//
+ .loanScheduleType(LoanScheduleType.CUMULATIVE.toString())//
+ .daysInYearType(DaysInYearType.ACTUAL)//
+ .daysInMonthType(DaysInMonthType.ACTUAL)//
+ .canDefineInstallmentAmount(true)//
+ .graceOnArrearsAgeing(3)//
+ .overdueDaysForNPA(179)//
+ .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
+ .principalThresholdForLastInstallment(50)//
+ .allowVariableInstallments(false)//
+ .canUseForTopup(false)//
+ .isInterestRecalculationEnabled(false)//
+ .holdGuaranteeFunds(false)//
+ .multiDisburseLoan(true)//
+ .allowAttributeOverrides(new AllowAttributeOverrides()//
+ .amortizationType(true)//
+ .interestType(true)//
+ .transactionProcessingStrategyCode(true)//
+ .interestCalculationPeriodType(true)//
+ .inArrearsTolerance(true)//
+ .repaymentEvery(true)//
+ .graceOnPrincipalAndInterestPayment(true)//
+ .graceOnArrearsAgeing(true))//
+ .allowPartialPeriodInterestCalcualtion(true)//
+ .maxTrancheCount(10)//
+ .outstandingLoanBalance(10000.0)//
+ .charges(Collections.emptyList())//
+ .accountingRule(3)//
+ .fundSourceAccountId(getLiabilityAccountId("fundSource"))//
+ .loanPortfolioAccountId(getAssetAccountId("loansReceivable"))//
+ .transfersInSuspenseAccountId(getAssetAccountId("suspense"))//
+ .interestOnLoanAccountId(getIncomeAccountId("interestIncome"))//
+ .incomeFromFeeAccountId(getIncomeAccountId("feeIncome"))//
+ .incomeFromPenaltyAccountId(getIncomeAccountId("penaltyIncome"))//
+ .incomeFromRecoveryAccountId(getIncomeAccountId("recoveries"))//
+ .writeOffAccountId(getExpenseAccountId("writtenOff"))//
+ .overpaymentLiabilityAccountId(getLiabilityAccountId("overpayment"))//
+ .receivableInterestAccountId(getAssetAccountId("interestReceivable"))//
+ .receivableFeeAccountId(getAssetAccountId("feeReceivable"))//
+ .receivablePenaltyAccountId(getAssetAccountId("penaltyReceivable"))//
+ .goodwillCreditAccountId(getExpenseAccountId("goodwillExpense"))//
+ .incomeFromGoodwillCreditInterestAccountId(getIncomeAccountId("interestIncomeChargeOff"))//
+ .incomeFromGoodwillCreditFeesAccountId(getIncomeAccountId("feeChargeOff"))//
+ .incomeFromGoodwillCreditPenaltyAccountId(getIncomeAccountId("feeChargeOff"))//
+ .incomeFromChargeOffInterestAccountId(getIncomeAccountId("interestIncomeChargeOff"))//
+ .incomeFromChargeOffFeesAccountId(getIncomeAccountId("feeChargeOff"))//
+ .incomeFromChargeOffPenaltyAccountId(getIncomeAccountId("penaltyChargeOff"))//
+ .chargeOffExpenseAccountId(getExpenseAccountId("chargeOff"))//
+ .chargeOffFraudExpenseAccountId(getExpenseAccountId("chargeOffFraud"))//
+ .dateFormat(LoanTestData.DATETIME_PATTERN)//
+ .locale("en_GB")//
+ .disallowExpectedDisbursements(true)//
+ .allowApprovedDisbursedAmountsOverApplied(true)//
+ .overAppliedCalculationType("percentage")//
+ .overAppliedNumber(50);
+ }
+
+ default PostLoanProductsRequest fourInstallmentsCumulative() {
+ return fourInstallmentsCumulativeTemplate().loanScheduleType(LoanScheduleType.CUMULATIVE.toString());
+ }
+
+ default PostLoanProductsRequest fourInstallmentsProgressive() {
+ return fourInstallmentsCumulativeTemplate().loanScheduleType("PROGRESSIVE").loanScheduleProcessingType("HORIZONTAL")
+ .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD);
+ }
+
+ default PostLoanProductsRequest fourInstallmentsProgressiveWithCapitalizedIncome() {
+ return fourInstallmentsProgressive().enableIncomeCapitalization(true)//
+ .capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT)//
+ .capitalizedIncomeStrategy(PostLoanProductsRequest.CapitalizedIncomeStrategyEnum.EQUAL_AMORTIZATION)//
+ .deferredIncomeLiabilityAccountId(getLiabilityAccountId("deferredIncomeLiability"))//
+ .incomeFromCapitalizationAccountId(getIncomeAccountId("feeIncome"))//
+ .capitalizedIncomeType(PostLoanProductsRequest.CapitalizedIncomeTypeEnum.FEE);
+ }
+
+ default PostLoanProductsRequest fourInstallmentsCumulativeTemplate() {
+ return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("4I_PRODUCT_", 6))//
+ .shortName(Utils.uniqueRandomStringGenerator("", 4))//
+ .description("4 installment product")//
+ .includeInBorrowerCycle(false)//
+ .useBorrowerCycle(false)//
+ .currencyCode("EUR")//
+ .digitsAfterDecimal(2)//
+ .principal(1000.0)//
+ .minPrincipal(100.0)//
+ .maxPrincipal(10000.0)//
+ .numberOfRepayments(4)//
+ .repaymentEvery(1)//
+ .repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L)//
+ .interestRatePerPeriod(10.0)//
+ .minInterestRatePerPeriod(0.0)//
+ .maxInterestRatePerPeriod(120.0)//
+ .interestRateFrequencyType(InterestRateFrequencyType.YEARS)//
+ .isLinkedToFloatingInterestRates(false)//
+ .allowVariableInstallments(false)//
+ .amortizationType(AmortizationType.EQUAL_INSTALLMENTS)//
+ .interestType(InterestType.DECLINING_BALANCE)//
+ .interestCalculationPeriodType(InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)//
+ .allowPartialPeriodInterestCalcualtion(false)//
+ .creditAllocation(List.of())//
+ .overdueDaysForNPA(179)//
+ .daysInMonthType(DaysInMonthType.DAYS_30)//
+ .daysInYearType(DaysInYearType.DAYS_360)//
+ .isInterestRecalculationEnabled(false)//
+ .canDefineInstallmentAmount(true)//
+ .repaymentStartDateType(1)//
+ .charges(List.of())//
+ .principalVariationsForBorrowerCycle(List.of())//
+ .interestRateVariationsForBorrowerCycle(List.of())//
+ .numberOfRepaymentVariationsForBorrowerCycle(List.of())//
+ .accountingRule(3)//
+ .canUseForTopup(false)//
+ .fundSourceAccountId(getLiabilityAccountId("fundSource"))//
+ .loanPortfolioAccountId(getAssetAccountId("loansReceivable"))//
+ .transfersInSuspenseAccountId(getAssetAccountId("suspense"))//
+ .interestOnLoanAccountId(getIncomeAccountId("interestIncome"))//
+ .incomeFromFeeAccountId(getIncomeAccountId("feeIncome"))//
+ .incomeFromPenaltyAccountId(getIncomeAccountId("penaltyIncome"))//
+ .incomeFromRecoveryAccountId(getIncomeAccountId("recoveries"))//
+ .writeOffAccountId(getExpenseAccountId("writtenOff"))//
+ .overpaymentLiabilityAccountId(getLiabilityAccountId("overpayment"))//
+ .receivableInterestAccountId(getAssetAccountId("interestReceivable"))//
+ .receivableFeeAccountId(getAssetAccountId("feeReceivable"))//
+ .receivablePenaltyAccountId(getAssetAccountId("penaltyReceivable"))//
+ .goodwillCreditAccountId(getExpenseAccountId("goodwillExpense"))//
+ .incomeFromGoodwillCreditInterestAccountId(getIncomeAccountId("interestIncomeChargeOff"))//
+ .incomeFromGoodwillCreditFeesAccountId(getIncomeAccountId("feeChargeOff"))//
+ .incomeFromGoodwillCreditPenaltyAccountId(getIncomeAccountId("feeChargeOff"))//
+ .incomeFromChargeOffInterestAccountId(getIncomeAccountId("interestIncomeChargeOff"))//
+ .incomeFromChargeOffFeesAccountId(getIncomeAccountId("feeChargeOff"))//
+ .incomeFromChargeOffPenaltyAccountId(getIncomeAccountId("penaltyChargeOff"))//
+ .chargeOffExpenseAccountId(getExpenseAccountId("chargeOff"))//
+ .chargeOffFraudExpenseAccountId(getExpenseAccountId("chargeOffFraud"))//
+ .dateFormat(LoanTestData.DATETIME_PATTERN)//
+ .locale("en")//
+ .enableAccrualActivityPosting(false)//
+ .multiDisburseLoan(false)//
+ .disallowExpectedDisbursements(false)//
+ .allowApprovedDisbursedAmountsOverApplied(true)//
+ .overAppliedCalculationType("percentage")//
+ .overAppliedNumber(50)//
+ .principalThresholdForLastInstallment(50)//
+ .holdGuaranteeFunds(false)//
+ .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
+ .allowAttributeOverrides(new AllowAttributeOverrides()//
+ .amortizationType(true)//
+ .interestType(true)//
+ .transactionProcessingStrategyCode(true)//
+ .interestCalculationPeriodType(true)//
+ .inArrearsTolerance(true)//
+ .repaymentEvery(true)//
+ .graceOnPrincipalAndInterestPayment(true)//
+ .graceOnArrearsAgeing(true))//
+ .isEqualAmortization(false)//
+ .enableDownPayment(false)//
+ .enableInstallmentLevelDelinquency(false)//
+ .transactionProcessingStrategyCode(
+ LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY);
+ }
+
+ default PostLoanProductsRequest customizeProduct(PostLoanProductsRequest template,
+ Function<PostLoanProductsRequest, PostLoanProductsRequest> customizer) {
+ return customizer.apply(template);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java
new file mode 100644
index 0000000000..ead6e9140c
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java
@@ -0,0 +1,135 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.modules;
+
+import java.math.BigDecimal;
+import org.apache.fineract.client.models.PostLoansLoanIdRequest;
+import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
+import org.apache.fineract.client.models.PostLoansRequest;
+
+public final class LoanRequestBuilders {
+
+ private LoanRequestBuilders() {}
+
+ public static PostLoansRequest applyLoan(Long clientId, Long productId, String submittedOnDate, Double principal,
+ Integer numberOfRepayments) {
+ return new PostLoansRequest()//
+ .clientId(clientId)//
+ .productId(productId)//
+ .loanType("individual")//
+ .submittedOnDate(submittedOnDate)//
+ .expectedDisbursementDate(submittedOnDate)//
+ .principal(BigDecimal.valueOf(principal))//
+ .loanTermFrequency(numberOfRepayments)//
+ .loanTermFrequencyType(LoanTestData.RepaymentFrequencyType.MONTHS)//
+ .numberOfRepayments(numberOfRepayments)//
+ .repaymentEvery(1)//
+ .repaymentFrequencyType(LoanTestData.RepaymentFrequencyType.MONTHS)//
+ .interestRatePerPeriod(BigDecimal.ZERO)//
+ .amortizationType(LoanTestData.AmortizationType.EQUAL_INSTALLMENTS)//
+ .interestType(LoanTestData.InterestType.DECLINING_BALANCE)//
+ .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.SAME_AS_REPAYMENT_PERIOD)//
+ .transactionProcessingStrategyCode("mifos-standard-strategy")//
+ .locale(LoanTestData.LOCALE)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN);
+ }
+
+ public static PostLoansRequest applyCumulativeLoan(Long clientId, Long productId, String submittedOnDate, Double principal,
+ Integer numberOfRepayments, Double interestRate) {
+ return new PostLoansRequest()//
+ .clientId(clientId)//
+ .productId(productId)//
+ .submittedOnDate(submittedOnDate)//
+ .expectedDisbursementDate(submittedOnDate)//
+ .principal(BigDecimal.valueOf(principal))//
+ .loanTermFrequency(numberOfRepayments)//
+ .loanTermFrequencyType(LoanTestData.RepaymentFrequencyType.MONTHS)//
+ .numberOfRepayments(numberOfRepayments)//
+ .repaymentEvery(1)//
+ .repaymentFrequencyType(LoanTestData.RepaymentFrequencyType.MONTHS)//
+ .interestRatePerPeriod(BigDecimal.valueOf(interestRate))//
+ .amortizationType(LoanTestData.AmortizationType.EQUAL_INSTALLMENTS)//
+ .interestType(LoanTestData.InterestType.DECLINING_BALANCE)//
+ .interestCalculationPeriodType(LoanTestData.InterestCalculationPeriodType.DAILY)//
+ .transactionProcessingStrategyCode("DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY")//
+ .loanType("individual")//
+ .locale(LoanTestData.LOCALE)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN);
+ }
+
+ public static PostLoansRequest applyProgressiveLoan(Long clientId, Long productId, String submittedOnDate, Double principal,
+ Integer numberOfRepayments, Double interestRate) {
+ return applyCumulativeLoan(clientId, productId, submittedOnDate, principal, numberOfRepayments, interestRate);
+ }
+
+ public static PostLoansLoanIdRequest approveLoan(Double approvedAmount, String approvedOnDate) {
+ return new PostLoansLoanIdRequest()//
+ .approvedLoanAmount(BigDecimal.valueOf(approvedAmount))//
+ .approvedOnDate(approvedOnDate)//
+ .locale(LoanTestData.LOCALE)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN);
+ }
+
+ public static PostLoansLoanIdRequest disburseLoan(Double disbursedAmount, String disbursedOnDate) {
+ return new PostLoansLoanIdRequest()//
+ .actualDisbursementDate(disbursedOnDate)//
+ .transactionAmount(BigDecimal.valueOf(disbursedAmount))//
+ .locale(LoanTestData.LOCALE)//
+ .dateFormat(LoanTestData.DATETIME_PATTERN);
+ }
+
+ public static PostLoansLoanIdTransactionsRequest repayLoan(Double amount, String transactionDate) {
+ PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
+ request.setTransactionDate(transactionDate);
+ request.setTransactionAmount(amount);
+ request.setLocale(LoanTestData.LOCALE);
+ request.setDateFormat(LoanTestData.DATETIME_PATTERN);
+ return request;
+ }
+
+ public static PostLoansLoanIdTransactionsRequest makeWaiver(Double amount, String transactionDate) {
+ PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
+ request.setTransactionDate(transactionDate);
+ request.setTransactionAmount(amount);
+ request.setLocale(LoanTestData.LOCALE);
+ request.setDateFormat(LoanTestData.DATETIME_PATTERN);
+ return request;
+ }
+
+ public static PostLoansLoanIdTransactionsRequest chargeOff(String transactionDate) {
+ PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
+ request.setTransactionDate(transactionDate);
+ request.setLocale(LoanTestData.LOCALE);
+ request.setDateFormat(LoanTestData.DATETIME_PATTERN);
+ return request;
+ }
+
+ public static PostLoansLoanIdTransactionsRequest addChargeback(Long transactionId, Double amount, String transactionDate) {
+ PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
+ request.setTransactionDate(transactionDate);
+ request.setTransactionAmount(amount);
+ request.setLocale(LoanTestData.LOCALE);
+ request.setDateFormat(LoanTestData.DATETIME_PATTERN);
+ return request;
+ }
+
+ public static PostLoansLoanIdTransactionsRequest waiveInterest(Double amount, String transactionDate) {
+ return makeWaiver(amount, transactionDate);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestAccounts.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestAccounts.java
new file mode 100644
index 0000000000..bed8fb5640
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestAccounts.java
@@ -0,0 +1,204 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.modules;
+
+import org.apache.fineract.integrationtests.client.feign.helpers.FeignAccountHelper;
+import org.apache.fineract.integrationtests.common.accounting.Account;
+
+public class LoanTestAccounts implements LoanProductTemplates {
+
+ private final Account loansReceivableAccount;
+ private final Account interestReceivableAccount;
+ private final Account feeReceivableAccount;
+ private final Account penaltyReceivableAccount;
+ private final Account suspenseAccount;
+ private final Account fundSource;
+ private final Account overpaymentAccount;
+ private final Account interestIncomeAccount;
+ private final Account feeIncomeAccount;
+ private final Account penaltyIncomeAccount;
+ private final Account feeChargeOffAccount;
+ private final Account penaltyChargeOffAccount;
+ private final Account recoveriesAccount;
+ private final Account interestIncomeChargeOffAccount;
+ private final Account chargeOffExpenseAccount;
+ private final Account chargeOffFraudExpenseAccount;
+ private final Account writtenOffAccount;
+ private final Account goodwillExpenseAccount;
+ private final Account goodwillIncomeAccount;
+ private final Account deferredIncomeLiabilityAccount;
+ private final Account buyDownExpenseAccount;
+
+ public LoanTestAccounts(FeignAccountHelper accountHelper) {
+ this.loansReceivableAccount = accountHelper.createAssetAccount("loanPortfolio");
+ this.interestReceivableAccount = accountHelper.createAssetAccount("interestReceivable");
+ this.feeReceivableAccount = accountHelper.createAssetAccount("feeReceivable");
+ this.penaltyReceivableAccount = accountHelper.createAssetAccount("penaltyReceivable");
+ this.suspenseAccount = accountHelper.createAssetAccount("suspense");
+ this.fundSource = accountHelper.createLiabilityAccount("fundSource");
+ this.overpaymentAccount = accountHelper.createLiabilityAccount("overpayment");
+ this.interestIncomeAccount = accountHelper.createIncomeAccount("interestIncome");
+ this.feeIncomeAccount = accountHelper.createIncomeAccount("feeIncome");
+ this.penaltyIncomeAccount = accountHelper.createIncomeAccount("penaltyIncome");
+ this.feeChargeOffAccount = accountHelper.createIncomeAccount("feeChargeOff");
+ this.penaltyChargeOffAccount = accountHelper.createIncomeAccount("penaltyChargeOff");
+ this.recoveriesAccount = accountHelper.createIncomeAccount("recoveries");
+ this.interestIncomeChargeOffAccount = accountHelper.createIncomeAccount("interestIncomeChargeOff");
+ this.chargeOffExpenseAccount = accountHelper.createExpenseAccount("chargeOff");
+ this.chargeOffFraudExpenseAccount = accountHelper.createExpenseAccount("chargeOffFraud");
+ this.writtenOffAccount = accountHelper.createExpenseAccount("writtenOffAccount");
+ this.goodwillExpenseAccount = accountHelper.createExpenseAccount("goodwillExpenseAccount");
+ this.goodwillIncomeAccount = accountHelper.createIncomeAccount("goodwillIncomeAccount");
+ this.deferredIncomeLiabilityAccount = accountHelper.createLiabilityAccount("deferredIncomeLiabilityAccount");
+ this.buyDownExpenseAccount = accountHelper.createExpenseAccount("buyDownExpenseAccount");
+ }
+
+ @Override
+ public Long getAssetAccountId(String accountName) {
+ return switch (accountName) {
+ case "loansReceivable" -> loansReceivableAccount.getAccountID().longValue();
+ case "interestReceivable" -> interestReceivableAccount.getAccountID().longValue();
+ case "feeReceivable" -> feeReceivableAccount.getAccountID().longValue();
+ case "penaltyReceivable" -> penaltyReceivableAccount.getAccountID().longValue();
+ case "suspense" -> suspenseAccount.getAccountID().longValue();
+ default -> throw new IllegalArgumentException("Unknown asset account: " + accountName);
+ };
+ }
+
+ @Override
+ public Long getLiabilityAccountId(String accountName) {
+ return switch (accountName) {
+ case "fundSource" -> fundSource.getAccountID().longValue();
+ case "overpayment" -> overpaymentAccount.getAccountID().longValue();
+ case "deferredIncomeLiability" -> deferredIncomeLiabilityAccount.getAccountID().longValue();
+ default -> throw new IllegalArgumentException("Unknown liability account: " + accountName);
+ };
+ }
+
+ @Override
+ public Long getIncomeAccountId(String accountName) {
+ return switch (accountName) {
+ case "interestIncome" -> interestIncomeAccount.getAccountID().longValue();
+ case "feeIncome" -> feeIncomeAccount.getAccountID().longValue();
+ case "penaltyIncome" -> penaltyIncomeAccount.getAccountID().longValue();
+ case "feeChargeOff" -> feeChargeOffAccount.getAccountID().longValue();
+ case "penaltyChargeOff" -> penaltyChargeOffAccount.getAccountID().longValue();
+ case "recoveries" -> recoveriesAccount.getAccountID().longValue();
+ case "interestIncomeChargeOff" -> interestIncomeChargeOffAccount.getAccountID().longValue();
+ case "goodwillIncome" -> goodwillIncomeAccount.getAccountID().longValue();
+ default -> throw new IllegalArgumentException("Unknown income account: " + accountName);
+ };
+ }
+
+ @Override
+ public Long getExpenseAccountId(String accountName) {
+ return switch (accountName) {
+ case "chargeOff" -> chargeOffExpenseAccount.getAccountID().longValue();
+ case "chargeOffFraud" -> chargeOffFraudExpenseAccount.getAccountID().longValue();
+ case "writtenOff" -> writtenOffAccount.getAccountID().longValue();
+ case "goodwillExpense" -> goodwillExpenseAccount.getAccountID().longValue();
+ case "buyDownExpense" -> buyDownExpenseAccount.getAccountID().longValue();
+ default -> throw new IllegalArgumentException("Unknown expense account: " + accountName);
+ };
+ }
+
+ public Account getLoansReceivableAccount() {
+ return loansReceivableAccount;
+ }
+
+ public Account getInterestReceivableAccount() {
+ return interestReceivableAccount;
+ }
+
+ public Account getFeeReceivableAccount() {
+ return feeReceivableAccount;
+ }
+
+ public Account getPenaltyReceivableAccount() {
+ return penaltyReceivableAccount;
+ }
+
+ public Account getSuspenseAccount() {
+ return suspenseAccount;
+ }
+
+ public Account getFundSource() {
+ return fundSource;
+ }
+
+ public Account getOverpaymentAccount() {
+ return overpaymentAccount;
+ }
+
+ public Account getInterestIncomeAccount() {
+ return interestIncomeAccount;
+ }
+
+ public Account getFeeIncomeAccount() {
+ return feeIncomeAccount;
+ }
+
+ public Account getPenaltyIncomeAccount() {
+ return penaltyIncomeAccount;
+ }
+
+ public Account getFeeChargeOffAccount() {
+ return feeChargeOffAccount;
+ }
+
+ public Account getPenaltyChargeOffAccount() {
+ return penaltyChargeOffAccount;
+ }
+
+ public Account getRecoveriesAccount() {
+ return recoveriesAccount;
+ }
+
+ public Account getInterestIncomeChargeOffAccount() {
+ return interestIncomeChargeOffAccount;
+ }
+
+ public Account getChargeOffExpenseAccount() {
+ return chargeOffExpenseAccount;
+ }
+
+ public Account getChargeOffFraudExpenseAccount() {
+ return chargeOffFraudExpenseAccount;
+ }
+
+ public Account getWrittenOffAccount() {
+ return writtenOffAccount;
+ }
+
+ public Account getGoodwillExpenseAccount() {
+ return goodwillExpenseAccount;
+ }
+
+ public Account getGoodwillIncomeAccount() {
+ return goodwillIncomeAccount;
+ }
+
+ public Account getDeferredIncomeLiabilityAccount() {
+ return deferredIncomeLiabilityAccount;
+ }
+
+ public Account getBuyDownExpenseAccount() {
+ return buyDownExpenseAccount;
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java
new file mode 100644
index 0000000000..0b42a26f9f
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java
@@ -0,0 +1,231 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.modules;
+
+import lombok.AllArgsConstructor;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.apache.fineract.integrationtests.common.accounting.Account;
+
+public final class LoanTestData {
+
+ public static final String DATETIME_PATTERN = "dd MMMM yyyy";
+ public static final String LOCALE = "en";
+
+ private LoanTestData() {}
+
+ @ToString
+ @AllArgsConstructor
+ public static class Transaction {
+
+ public Double amount;
+ public String type;
+ public String date;
+ public Boolean reversed;
+ }
+
+ @ToString
+ @AllArgsConstructor
+ public static class TransactionExt {
+
+ public Double amount;
+ public String type;
+ public String date;
+ public Double outstandingPrincipal;
+ public Double principalPortion;
+ public Double interestPortion;
+ public Double feePortion;
+ public Double penaltyPortion;
+ public Double unrecognizedPortion;
+ public Double overpaymentPortion;
+ public Boolean reversed;
+ }
+
+ @ToString
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class Journal {
+
+ public Double amount;
+ public Account account;
+ public String type;
+
+ public static Journal debit(Long glAccountId, double amount) {
+ Journal journal = new Journal();
+ journal.amount = amount;
+ journal.account = new Account(glAccountId.intValue(), null);
+ journal.type = "DEBIT";
+ return journal;
+ }
+
+ public static Journal credit(Long glAccountId, double amount) {
+ Journal journal = new Journal();
+ journal.amount = amount;
+ journal.account = new Account(glAccountId.intValue(), null);
+ journal.type = "CREDIT";
+ return journal;
+ }
+ }
+
+ @ToString
+ @AllArgsConstructor
+ public static class Installment {
+
+ public Double principalAmount;
+ public Double interestAmount;
+ public Double feeAmount;
+ public Double penaltyAmount;
+ public Double totalOutstandingAmount;
+ public Boolean completed;
+ public String dueDate;
+ public OutstandingAmounts outstandingAmounts;
+ public Double loanBalance;
+ }
+
+ @AllArgsConstructor
+ @ToString
+ public static class OutstandingAmounts {
+
+ public Double principalOutstanding;
+ public Double interestOutstanding;
+ public Double feeOutstanding;
+ public Double penaltyOutstanding;
+ public Double totalOutstanding;
+ }
+
+ public static final class AmortizationType {
+
+ public static final Integer EQUAL_INSTALLMENTS = 1;
+
+ private AmortizationType() {}
+ }
+
+ public static final class InterestType {
+
+ public static final Integer DECLINING_BALANCE = 0;
+ public static final Integer FLAT = 1;
+
+ private InterestType() {}
+ }
+
+ public static final class InterestRecalculationCompoundingMethod {
+
+ public static final Integer NONE = 0;
+
+ private InterestRecalculationCompoundingMethod() {}
+ }
+
+ public static final class RepaymentFrequencyType {
+
+ public static final Integer MONTHS = 2;
+ public static final Long MONTHS_L = 2L;
+ public static final String MONTHS_STRING = "MONTHS";
+ public static final Integer DAYS = 0;
+ public static final Long DAYS_L = 0L;
+ public static final String DAYS_STRING = "DAYS";
+
+ private RepaymentFrequencyType() {}
+ }
+
+ public static final class RecalculationRestFrequencyType {
+
+ public static final Integer SAME_AS_REPAYMENT_PERIOD = 1;
+ public static final Integer DAILY = 2;
+
+ private RecalculationRestFrequencyType() {}
+ }
+
+ public static final class InterestCalculationPeriodType {
+
+ public static final Integer DAILY = 0;
+ public static final Integer SAME_AS_REPAYMENT_PERIOD = 1;
+
+ private InterestCalculationPeriodType() {}
+ }
+
+ public static final class InterestRateFrequencyType {
+
+ public static final Integer MONTHS = 2;
+ public static final Integer YEARS = 3;
+ public static final Integer WHOLE_TERM = 4;
+
+ private InterestRateFrequencyType() {}
+ }
+
+ public static final class TransactionProcessingStrategyCode {
+
+ public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy";
+
+ private TransactionProcessingStrategyCode() {}
+ }
+
+ public static final class RescheduleStrategyMethod {
+
+ public static final Integer RESCHEDULE_NEXT_REPAYMENTS = 1;
+ public static final Integer REDUCE_EMI_AMOUNT = 3;
+ public static final Integer ADJUST_LAST_UNPAID_PERIOD = 4;
+
+ private RescheduleStrategyMethod() {}
+ }
+
+ public static final class DaysInYearType {
+
+ public static final Integer INVALID = 0;
+ public static final Integer ACTUAL = 1;
+ public static final Integer DAYS_360 = 360;
+ public static final Integer DAYS_364 = 364;
+ public static final Integer DAYS_365 = 365;
+
+ private DaysInYearType() {}
+ }
+
+ public static final class DaysInMonthType {
+
+ public static final Integer INVALID = 0;
+ public static final Integer ACTUAL = 1;
+ public static final Integer DAYS_30 = 30;
+
+ private DaysInMonthType() {}
+ }
+
+ public static final class FuturePaymentAllocationRule {
+
+ public static final String LAST_INSTALLMENT = "LAST_INSTALLMENT";
+ public static final String NEXT_INSTALLMENT = "NEXT_INSTALLMENT";
+ public static final String NEXT_LAST_INSTALLMENT = "NEXT_LAST_INSTALLMENT";
+
+ private FuturePaymentAllocationRule() {}
+ }
+
+ public static final class SupportedInterestRefundTypesItem {
+
+ public static final String MERCHANT_ISSUED_REFUND = "MERCHANT_ISSUED_REFUND";
+ public static final String PAYOUT_REFUND = "PAYOUT_REFUND";
+
+ private SupportedInterestRefundTypesItem() {}
+ }
+
+ public static final class DaysInYearCustomStrategy {
+
+ public static final String FEB_29_PERIOD_ONLY = "FEB_29_PERIOD_ONLY";
+ public static final String FULL_LEAP_YEAR = "FULL_LEAP_YEAR";
+
+ private DaysInYearCustomStrategy() {}
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestValidators.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestValidators.java
new file mode 100644
index 0000000000..211661b2cc
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestValidators.java
@@ -0,0 +1,126 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.modules;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.Function;
+import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
+import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.GetLoansLoanIdStatus;
+import org.apache.fineract.integrationtests.common.Utils;
+
+public final class LoanTestValidators {
+
+ private LoanTestValidators() {}
+
+ public static void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue,
+ double principalPaid, double principalOutstanding, double paidInAdvance, double paidLate) {
+ GetLoansLoanIdRepaymentPeriod period = loanDetails.getRepaymentSchedule().getPeriods().stream()
+ .filter(p -> Objects.equals(p.getPeriod(), index)).findFirst().orElseThrow();
+ assertEquals(dueDate, period.getDueDate());
+ assertEquals(principalDue, Utils.getDoubleValue(period.getPrincipalDue()));
+ assertEquals(principalPaid, Utils.getDoubleValue(period.getPrincipalPaid()));
+ assertEquals(principalOutstanding, Utils.getDoubleValue(period.getPrincipalOutstanding()));
+ assertEquals(paidInAdvance, Utils.getDoubleValue(period.getTotalPaidInAdvanceForPeriod()));
+ assertEquals(paidLate, Utils.getDoubleValue(period.getTotalPaidLateForPeriod()));
+ }
+
+ public static void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, double principalDue, double principalPaid,
+ double principalOutstanding, double paidInAdvance, double paidLate) {
+ GetLoansLoanIdRepaymentPeriod period = loanDetails.getRepaymentSchedule().getPeriods().stream()
+ .filter(p -> Objects.equals(p.getPeriod(), index)).findFirst().orElseThrow();
+ assertEquals(principalDue, Utils.getDoubleValue(period.getPrincipalDue()));
+ assertEquals(principalPaid, Utils.getDoubleValue(period.getPrincipalPaid()));
+ assertEquals(principalOutstanding, Utils.getDoubleValue(period.getPrincipalOutstanding()));
+ assertEquals(paidInAdvance, Utils.getDoubleValue(period.getTotalPaidInAdvanceForPeriod()));
+ assertEquals(paidLate, Utils.getDoubleValue(period.getTotalPaidLateForPeriod()));
+ }
+
+ public static void validateFullyUnpaidRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, String dueDate,
+ double principalDue, double feeDue, double penaltyDue, double interestDue) {
+ validateRepaymentPeriod(loanDetails, index,
+ LocalDate.parse(dueDate, DateTimeFormatter.ofPattern(LoanTestData.DATETIME_PATTERN, Locale.ENGLISH)), principalDue, 0,
+ principalDue, feeDue, 0, feeDue, penaltyDue, 0, penaltyDue, interestDue, 0, interestDue, 0, 0);
+ }
+
+ public static void validateFullyPaidRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, String dueDate,
+ double principalDue, double feeDue, double penaltyDue, double interestDue) {
+ validateRepaymentPeriod(loanDetails, index,
+ LocalDate.parse(dueDate, DateTimeFormatter.ofPattern(LoanTestData.DATETIME_PATTERN, Locale.ENGLISH)), principalDue,
+ principalDue, 0, feeDue, feeDue, 0, penaltyDue, penaltyDue, 0, interestDue, interestDue, 0, 0, 0);
+ }
+
+ public static void validateFullyPaidRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, String dueDate,
+ double principalDue, double feeDue, double penaltyDue, double interestDue, double paidLate) {
+ validateRepaymentPeriod(loanDetails, index,
+ LocalDate.parse(dueDate, DateTimeFormatter.ofPattern(LoanTestData.DATETIME_PATTERN, Locale.ENGLISH)), principalDue,
+ principalDue, 0, feeDue, feeDue, 0, penaltyDue, penaltyDue, 0, interestDue, interestDue, 0, 0, paidLate);
+ }
+
+ public static void validateFullyPaidRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, String dueDate,
+ double principalDue, double feeDue, double penaltyDue, double interestDue, double paidLate, double paidInAdvance) {
+ validateRepaymentPeriod(loanDetails, index,
+ LocalDate.parse(dueDate, DateTimeFormatter.ofPattern(LoanTestData.DATETIME_PATTERN, Locale.ENGLISH)), principalDue,
+ principalDue, 0, feeDue, feeDue, 0, penaltyDue, penaltyDue, 0, interestDue, interestDue, 0, paidInAdvance, paidLate);
+ }
+
+ public static void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue,
+ double feeDue, double penaltyDue, double interestDue) {
+ validateRepaymentPeriod(loanDetails, index, dueDate, principalDue, 0, principalDue, feeDue, 0, feeDue, penaltyDue, 0, penaltyDue,
+ interestDue, 0, interestDue, 0, 0);
+ }
+
+ public static void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue,
+ double principalPaid, double principalOutstanding, double feeDue, double feePaid, double feeOutstanding, double penaltyDue,
+ double penaltyPaid, double penaltyOutstanding, double interestDue, double interestPaid, double interestOutstanding,
+ double paidInAdvance, double paidLate) {
+ GetLoansLoanIdRepaymentPeriod period = loanDetails.getRepaymentSchedule().getPeriods().stream()
+ .filter(p -> Objects.equals(p.getPeriod(), index)).findFirst().orElseThrow();
+ assertEquals(dueDate, period.getDueDate());
+ assertEquals(principalDue, Utils.getDoubleValue(period.getPrincipalDue()));
+ assertEquals(principalPaid, Utils.getDoubleValue(period.getPrincipalPaid()));
+ assertEquals(principalOutstanding, Utils.getDoubleValue(period.getPrincipalOutstanding()));
+ assertEquals(feeDue, Utils.getDoubleValue(period.getFeeChargesDue()));
+ assertEquals(feePaid, Utils.getDoubleValue(period.getFeeChargesPaid()));
+ assertEquals(feeOutstanding, Utils.getDoubleValue(period.getFeeChargesOutstanding()));
+ assertEquals(penaltyDue, Utils.getDoubleValue(period.getPenaltyChargesDue()));
+ assertEquals(penaltyPaid, Utils.getDoubleValue(period.getPenaltyChargesPaid()));
+ assertEquals(penaltyOutstanding, Utils.getDoubleValue(period.getPenaltyChargesOutstanding()));
+ assertEquals(interestDue, Utils.getDoubleValue(period.getInterestDue()));
+ assertEquals(interestPaid, Utils.getDoubleValue(period.getInterestPaid()));
+ assertEquals(interestOutstanding, Utils.getDoubleValue(period.getInterestOutstanding()));
+ assertEquals(paidInAdvance, Utils.getDoubleValue(period.getTotalPaidInAdvanceForPeriod()));
+ assertEquals(paidLate, Utils.getDoubleValue(period.getTotalPaidLateForPeriod()));
+ }
+
+ public static void verifyLoanStatus(GetLoansLoanIdResponse loanDetails, Function<GetLoansLoanIdStatus, Boolean> extractor) {
+ assertNotNull(loanDetails);
+ assertNotNull(loanDetails.getStatus());
+ Boolean actualValue = extractor.apply(loanDetails.getStatus());
+ assertNotNull(actualValue);
+ assertTrue(actualValue);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanCreationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanCreationTest.java
new file mode 100644
index 0000000000..06ec3d4948
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanCreationTest.java
@@ -0,0 +1,159 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.time.LocalDate;
+import org.apache.fineract.client.models.GetLoansLoanIdResponse;
+import org.apache.fineract.client.models.PostLoanProductsRequest;
+import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase;
+import org.apache.fineract.integrationtests.common.Utils;
+import org.junit.jupiter.api.Test;
+
+public class FeignLoanCreationTest extends FeignLoanTestBase {
+
+ @Test
+ void testCreateAndDisburseLoan_OnePeriodNoInterest() {
+ Long clientId = createClient("John", "Doe");
+ assertNotNull(clientId);
+
+ PostLoanProductsRequest productRequest = onePeriod30DaysNoInterest();
+ Long productId = createLoanProduct(productRequest);
+ assertNotNull(productId);
+
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApproveAndDisburseLoan(clientId, productId, todayDate, 1000.0, 1);
+ assertNotNull(loanId);
+
+ GetLoansLoanIdResponse loan = getLoanDetails(loanId);
+ assertNotNull(loan);
+ verifyLoanStatus(loan, status -> status.getActive());
+
+ LocalDate expectedRepaymentDate = Utils.getLocalDateOfTenant().plusMonths(1);
+ validateRepaymentPeriod(loan, 1, expectedRepaymentDate, 1000.0, 0.0, 1000.0);
+
+ verifyJournalEntries(loanId, debit(getAccounts().getLoansReceivableAccount().getAccountID().longValue(), 1000.0),
+ credit(getAccounts().getFundSource().getAccountID().longValue(), 1000.0));
+ }
+
+ @Test
+ void testLoanRepayment_FullRepayment() {
+ Long clientId = createClient("Jane", "Smith");
+ Long productId = createLoanProduct(onePeriod30DaysNoInterest());
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApproveAndDisburseLoan(clientId, productId, todayDate, 1000.0, 1);
+
+ Long repaymentId = addRepayment(loanId, repayment(1000.0, todayDate));
+ assertNotNull(repaymentId);
+
+ GetLoansLoanIdResponse loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, status -> status.getClosedObligationsMet());
+
+ assertEquals(0.0, Utils.getDoubleValue(loan.getSummary().getTotalOutstanding()));
+ assertEquals(1000.0, Utils.getDoubleValue(loan.getSummary().getTotalRepayment()));
+ }
+
+ @Test
+ void testLoanUndoApproval() {
+ Long clientId = createClient("Bob", "Johnson");
+ Long productId = createLoanProduct(onePeriod30DaysNoInterest());
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApprovedLoan(clientId, productId, todayDate, 1000.0, 1);
+
+ GetLoansLoanIdResponse loanBeforeUndo = getLoanDetails(loanId);
+ verifyLoanStatus(loanBeforeUndo, status -> status.getWaitingForDisbursal());
+
+ undoApproval(loanId);
+
+ GetLoansLoanIdResponse loanAfterUndo = getLoanDetails(loanId);
+ verifyLoanStatus(loanAfterUndo, status -> status.getPendingApproval());
+ }
+
+ @Test
+ void testLoanUndoDisbursement() {
+ Long clientId = createClient("Alice", "Williams");
+ Long productId = createLoanProduct(onePeriod30DaysNoInterest());
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApproveAndDisburseLoan(clientId, productId, todayDate, 1000.0, 1);
+
+ GetLoansLoanIdResponse loanBeforeUndo = getLoanDetails(loanId);
+ verifyLoanStatus(loanBeforeUndo, status -> status.getActive());
+
+ undoDisbursement(loanId);
+
+ GetLoansLoanIdResponse loanAfterUndo = getLoanDetails(loanId);
+ verifyLoanStatus(loanAfterUndo, status -> status.getWaitingForDisbursal());
+ }
+
+ @Test
+ void testFourInstallmentLoan_CumulativeInterest() {
+ Long clientId = createClient("Charlie", "Brown");
+ Long productId = createLoanProduct(fourInstallmentsCumulative());
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApproveAndDisburseLoan(clientId, productId, todayDate, 1000.0, 4);
+
+ GetLoansLoanIdResponse loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, status -> status.getActive());
+
+ assertNotNull(loan.getRepaymentSchedule());
+ assertNotNull(loan.getRepaymentSchedule().getPeriods());
+ assertEquals(5, loan.getRepaymentSchedule().getPeriods().size());
+ }
+
+ @Test
+ void testPartialRepayment() {
+ Long clientId = createClient("David", "Miller");
+ Long productId = createLoanProduct(onePeriod30DaysNoInterest());
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApproveAndDisburseLoan(clientId, productId, todayDate, 1000.0, 1);
+
+ Long repaymentId = addRepayment(loanId, repayment(500.0, todayDate));
+ assertNotNull(repaymentId);
+
+ GetLoansLoanIdResponse loan = getLoanDetails(loanId);
+ verifyLoanStatus(loan, status -> status.getActive());
+
+ assertEquals(500.0, Utils.getDoubleValue(loan.getSummary().getTotalOutstanding()));
+ assertEquals(500.0, Utils.getDoubleValue(loan.getSummary().getTotalRepayment()));
+ }
+
+ @Test
+ void testUndoRepayment() {
+ Long clientId = createClient("Eva", "Davis");
+ Long productId = createLoanProduct(onePeriod30DaysNoInterest());
+ String todayDate = Utils.dateFormatter.format(Utils.getLocalDateOfTenant());
+ Long loanId = createApproveAndDisburseLoan(clientId, productId, todayDate, 1000.0, 1);
+
+ Long repaymentId = addRepayment(loanId, repayment(1000.0, todayDate));
+ assertNotNull(repaymentId);
+
+ GetLoansLoanIdResponse loanAfterRepayment = getLoanDetails(loanId);
+ verifyLoanStatus(loanAfterRepayment, status -> status.getClosedObligationsMet());
+
+ undoRepayment(loanId, repaymentId, todayDate);
+
+ GetLoansLoanIdResponse loanAfterUndo = getLoanDetails(loanId);
+ verifyLoanStatus(loanAfterUndo, status -> status.getActive());
+
+ LocalDate expectedRepaymentDate = Utils.getLocalDateOfTenant().plusMonths(1);
+ validateRepaymentPeriod(loanAfterUndo, 1, expectedRepaymentDate, 1000.0, 0.0, 1000.0);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanTestBaseSmokeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanTestBaseSmokeTest.java
new file mode 100644
index 0000000000..02fd158502
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanTestBaseSmokeTest.java
@@ -0,0 +1,45 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.integrationtests.client.feign.tests;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.fineract.integrationtests.client.feign.FeignLoanTestBase;
+import org.junit.jupiter.api.Test;
+
+public class FeignLoanTestBaseSmokeTest extends FeignLoanTestBase {
+
+ @Test
+ void testInfrastructureInitialization() {
+ assertNotNull(accountHelper);
+ assertNotNull(loanHelper);
+ assertNotNull(transactionHelper);
+ assertNotNull(journalHelper);
+ assertNotNull(businessDateHelper);
+ assertNotNull(clientHelper);
+ assertNotNull(getAccounts());
+ assertNotNull(getAccounts().getLoansReceivableAccount());
+ }
+
+ @Test
+ void testCreateClient() {
+ Long clientId = createClient("Test", "User");
+ assertNotNull(clientId);
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/cob/CobPartitioningTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/cob/CobPartitioningTest.java
index c511e303b1..7d054cea69 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/cob/CobPartitioningTest.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/cob/CobPartitioningTest.java
@@ -59,6 +59,7 @@ import org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuil
import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder;
import org.apache.fineract.integrationtests.common.loans.LoanStatusChecker;
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -106,6 +107,12 @@ public class CobPartitioningTest extends BaseLoanIntegrationTest {
setProperFinancialActivity(TRANSFER_ACCOUNT);
}
+ @AfterEach
+ public void tearDown() {
+ // Restore business date configuration after each test
+ cleanUpAndRestoreBusinessDate();
+ }
+
private static void setProperFinancialActivity(Account transferAccount) {
List<GetFinancialActivityAccountsResponse> financialMappings = FINANCIAL_ACTIVITY_ACCOUNT_HELPER.getAllFinancialActivityAccounts();
financialMappings.forEach(mapping -> FINANCIAL_ACTIVITY_ACCOUNT_HELPER.deleteFinancialActivityAccount(mapping.getId()));
@@ -144,13 +151,16 @@ public class CobPartitioningTest extends BaseLoanIntegrationTest {
// Force close loans 3, 4, ... , N-3, N-2
Collections.sort(loanIds);
+ List<Integer> forelosedLoanIds = new ArrayList<>();
final CountDownLatch closeLatch = new CountDownLatch(N - 5);
// Warm up (EclipseLink sometimes fails if JPQL cache is not warm up but concurrent queries are executed)
LOAN_TRANSACTION_HELPER.forecloseLoan("02 March 2020", loanIds.get(2));
+ forelosedLoanIds.add(loanIds.get(2));
for (int i = 3; i < N - 2; i++) {
final int idx = i;
futures.add(executorService.submit(() -> {
LOAN_TRANSACTION_HELPER.forecloseLoan("02 March 2020", loanIds.get(idx));
+ forelosedLoanIds.add(loanIds.get(idx));
closeLatch.countDown();
}));
}
@@ -163,24 +173,45 @@ public class CobPartitioningTest extends BaseLoanIntegrationTest {
});
closeLatch.await();
- // Let's retrieve the partitions
- List<Map<String, Object>> cobPartitions = CobHelper.getCobPartitions(REQUEST_SPEC, RESPONSE_SPEC, 3, "");
- log.info("\nLoans created: {},\nRetrieved partitions: {}", loanIds, cobPartitions);
- Assertions.assertEquals(2, cobPartitions.size());
-
- Assertions.assertEquals(0, cobPartitions.get(0).get("pageNo"));
- Assertions.assertEquals(loanIds.get(0), cobPartitions.get(0).get("minId"));
- Assertions.assertEquals(loanIds.get(8), cobPartitions.get(0).get("maxId"));
- Assertions.assertEquals(3, cobPartitions.get(0).get("count"));
+ // Create list of active (non-foreclosed) loan IDs
+ List<Integer> activeLoanIds = new ArrayList<>(loanIds);
+ activeLoanIds.removeAll(forelosedLoanIds);
+ log.info("Active loan IDs after foreclosure: {}", activeLoanIds);
- Assertions.assertEquals(1, cobPartitions.get(1).get("pageNo"));
- Assertions.assertEquals(loanIds.get(9), cobPartitions.get(1).get("minId"));
- Assertions.assertEquals(loanIds.get(9), cobPartitions.get(1).get("maxId"));
- Assertions.assertEquals(1, cobPartitions.get(1).get("count"));
+ // Let's retrieve the partitions
+ List<Map<String, Object>> allPartitions = CobHelper.getCobPartitions(REQUEST_SPEC, RESPONSE_SPEC, 3, "");
+ log.info("\nLoans created by this test: {}\nAll partitions retrieved: {}", loanIds, allPartitions);
+
+ // Filter partitions to only include ACTIVE loans created by THIS test
+ // This ensures test isolation - we don't care about leftover loans from other tests
+ List<Map<String, Object>> testLoanPartitions = filterPartitionsForTestLoans(allPartitions, activeLoanIds);
+ log.info("Filtered partitions containing only our active test loans: {}", testLoanPartitions);
+
+ // Verify partitioning works correctly for our 4 active loans (10 created - 6 foreclosed)
+ // Expected: 2 partitions with page size 3
+ // Partition 0: loans 0, 1, 8 (3 loans)
+ // Partition 1: loan 9 (1 loan)
+ Assertions.assertEquals(2, testLoanPartitions.size(), "Expected 2 partitions for our 4 active test loans");
+
+ // Verify first partition
+ Assertions.assertEquals(0, testLoanPartitions.get(0).get("pageNo"));
+ Assertions.assertEquals(3, testLoanPartitions.get(0).get("count"));
+ Assertions.assertTrue(isLoanIdInRange(loanIds.get(0), testLoanPartitions.get(0)),
+ "First active loan should be in first partition");
+ Assertions.assertTrue(isLoanIdInRange(loanIds.get(1), testLoanPartitions.get(0)),
+ "Second active loan should be in first partition");
+ Assertions.assertTrue(isLoanIdInRange(loanIds.get(8), testLoanPartitions.get(0)),
+ "Ninth active loan should be in first partition");
+
+ // Verify second partition
+ Assertions.assertEquals(1, testLoanPartitions.get(1).get("pageNo"));
+ Assertions.assertEquals(1, testLoanPartitions.get(1).get("count"));
+ Assertions.assertTrue(isLoanIdInRange(loanIds.get(9), testLoanPartitions.get(1)), "Tenth loan should be in second partition");
executorService.shutdown();
- } finally {
- cleanUpAndRestoreBusinessDate();
+ } catch (Exception e) {
+ log.error("Test failed with exception", e);
+ throw e;
}
}
@@ -291,4 +322,41 @@ public class CobPartitioningTest extends BaseLoanIntegrationTest {
return collateral;
}
+ /**
+ * Filters partitions to only include those containing loans from the test's loan IDs. This ensures test isolation
+ * by ignoring any leftover loans from previous test runs.
+ */
+ private List<Map<String, Object>> filterPartitionsForTestLoans(List<Map<String, Object>> allPartitions, List<Integer> testLoanIds) {
+ List<Map<String, Object>> filteredPartitions = new ArrayList<>();
+
+ for (Map<String, Object> partition : allPartitions) {
+ Integer minId = (Integer) partition.get("minId");
+ Integer maxId = (Integer) partition.get("maxId");
+
+ // Check if this partition contains any of our test loan IDs
+ boolean containsTestLoans = testLoanIds.stream().anyMatch(loanId -> loanId >= minId && loanId <= maxId);
+
+ if (containsTestLoans) {
+ // Count how many of OUR loans are in this partition's range
+ long testLoansInPartition = testLoanIds.stream().filter(loanId -> loanId >= minId && loanId <= maxId).count();
+
+ // Create a new partition entry with corrected count
+ Map<String, Object> filteredPartition = new HashMap<>(partition);
+ filteredPartition.put("count", (int) testLoansInPartition);
+ filteredPartitions.add(filteredPartition);
+ }
+ }
+
+ return filteredPartitions;
+ }
+
+ /**
+ * Checks if a loan ID falls within the min/max range of a partition.
+ */
+ private boolean isLoanIdInRange(Integer loanId, Map<String, Object> partition) {
+ Integer minId = (Integer) partition.get("minId");
+ Integer maxId = (Integer) partition.get("maxId");
+ return loanId >= minId && loanId <= maxId;
+ }
+
}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/FineractFeignClientHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/FineractFeignClientHelper.java
new file mode 100644
index 0000000000..d909989db0
--- /dev/null
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/FineractFeignClientHelper.java
@@ -0,0 +1,58 @@
+/**
+ * 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.common;
+
+import java.util.function.Consumer;
+import java.util.function.Function;
+import org.apache.fineract.client.feign.FineractFeignClient;
+import org.apache.fineract.integrationtests.ConfigProperties;
+
+public final class FineractFeignClientHelper {
+
+ private static final FineractFeignClient DEFAULT_FINERACT_FEIGN_CLIENT = createNewFineractFeignClient(ConfigProperties.Backend.USERNAME,
+ ConfigProperties.Backend.PASSWORD);
+
+ private FineractFeignClientHelper() {}
+
+ public static FineractFeignClient getFineractFeignClient() {
+ return DEFAULT_FINERACT_FEIGN_CLIENT;
+ }
+
+ public static FineractFeignClient createNewFineractFeignClient(String username, String password) {
+ return createNewFineractFeignClient(username, password, Function.identity()::apply);
+ }
+
+ public static FineractFeignClient createNewFineractFeignClient(String username, String password, boolean debugEnabled) {
+ return createNewFineractFeignClient(username, password, builder -> builder.debug(debugEnabled));
+ }
+
+ public static FineractFeignClient createNewFineractFeignClient(String username, String password,
+ Consumer<FineractFeignClient.Builder> customizer) {
+ String url = System.getProperty("fineract.it.url", buildURI());
+ FineractFeignClient.Builder builder = FineractFeignClient.builder().baseUrl(url).credentials(username, password)
+ .disableSslVerification(true);
+ customizer.accept(builder);
+ return builder.build();
+ }
+
+ private static String buildURI() {
+ return ConfigProperties.Backend.PROTOCOL + "://" + ConfigProperties.Backend.HOST + ":" + ConfigProperties.Backend.PORT
+ + "/fineract-provider/api";
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index de7530c1a1..c8cf0e47ae 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -67,6 +67,7 @@ include ':integration-tests'
include ':twofactor-tests'
include ':oauth2-tests'
include ':fineract-client'
+include ':fineract-client-feign'
include ':fineract-doc'
include ':fineract-avro-schemas'
include ':fineract-e2e-tests-core'