From 3c899d291248af1455019e140ff3034459cc2e47 Mon Sep 17 00:00:00 2001 From: Nickesh Date: Thu, 9 Oct 2025 18:43:37 +0530 Subject: [PATCH 01/36] FINERACT-2391: check for AppUser type before casting Authentication Principal --- .../fineract/infrastructure/core/domain/AuditorAwareImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/AuditorAwareImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/AuditorAwareImpl.java index 981dd8aa9df..fc93b7f6ce6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/AuditorAwareImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/AuditorAwareImpl.java @@ -35,7 +35,7 @@ public Optional getCurrentAuditor() { final SecurityContext securityContext = SecurityContextHolder.getContext(); if (securityContext != null) { final Authentication authentication = securityContext.getAuthentication(); - if (authentication != null) { + if (authentication != null && authentication.getPrincipal() instanceof AppUser) { currentUserId = Optional.ofNullable(((AppUser) authentication.getPrincipal()).getId()); } else { currentUserId = retrieveSuperUser(); From f80ee9f50609e381c40e99bac5f831ae751f0fab Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Fri, 3 Oct 2025 08:12:14 +0200 Subject: [PATCH 02/36] FINERACT-2380: create feign client --- build.gradle | 5 + .../.openapi-generator-ignore | 32 + fineract-client-feign/build.gradle | 194 ++++ fineract-client-feign/dependencies.gradle | 49 + .../client/adapter/ExternalIdAdapter.java | 84 ++ .../feign/BasicAuthRequestInterceptor.java | 56 + .../fineract/client/feign/FeignException.java | 115 ++ .../client/feign/FineractErrorDecoder.java | 107 ++ .../client/feign/FineractFeignClient.java | 1002 +++++++++++++++++ .../feign/FineractFeignClientConfig.java | 293 +++++ .../feign/FineractMultipartEncoder.java | 198 ++++ .../client/feign/ObjectMapperFactory.java | 73 ++ .../feign/TenantIdRequestInterceptor.java | 42 + .../feign/services/DocumentsApiFixed.java | 140 +++ .../client/feign/services/ImagesApi.java | 97 ++ .../client/feign/services/RunReportsApi.java | 66 ++ .../util/CallFailedRuntimeException.java | 91 ++ .../client/feign/util/FeignCalls.java | 80 ++ .../fineract/client/util/FeignParts.java | 136 +++ .../resources/templates/java/api.mustache | 47 + .../client/adapter/ExternalIdAdapterTest.java | 192 ++++ .../client/feign/FeignExceptionTest.java | 140 +++ .../feign/FineractErrorDecoderTest.java | 180 +++ .../feign/FineractFeignClientConfigTest.java | 93 ++ .../client/feign/ObjectMapperFactoryTest.java | 120 ++ .../EncoderDecoderIntegrationTest.java | 421 +++++++ .../FineractFeignClientIntegrationTest.java | 252 +++++ .../ConnectionPoolPerformanceTest.java | 269 +++++ .../DocumentsApiFixedIntegrationTest.java | 208 ++++ .../services/ImagesApiIntegrationTest.java | 180 +++ .../RunReportsApiIntegrationTest.java | 166 +++ .../fineract/client/util/FeignPartsTest.java | 296 +++++ .../BasicPasswordEncodablePlatformUser.java | 12 +- integration-tests/build.gradle | 62 +- integration-tests/dependencies.gradle | 13 +- .../client/FeignClientSmokeTest.java | 65 ++ .../client/FeignDocumentTest.java | 150 +++ .../client/FeignImageTest.java | 111 ++ .../client/FeignIntegrationTest.java | 103 ++ .../client/feign/FeignLoanTestBase.java | 219 ++++ .../feign/helpers/FeignAccountHelper.java | 95 ++ .../helpers/FeignBusinessDateHelper.java | 62 + .../feign/helpers/FeignClientHelper.java | 65 ++ .../helpers/FeignJournalEntryHelper.java | 69 ++ .../client/feign/helpers/FeignLoanHelper.java | 109 ++ .../feign/helpers/FeignTransactionHelper.java | 75 ++ .../feign/modules/LoanProductTemplates.java | 240 ++++ .../feign/modules/LoanRequestBuilders.java | 135 +++ .../feign/modules/LoanTestAccounts.java | 204 ++++ .../client/feign/modules/LoanTestData.java | 231 ++++ .../feign/modules/LoanTestValidators.java | 126 +++ .../feign/tests/FeignLoanCreationTest.java | 159 +++ .../tests/FeignLoanTestBaseSmokeTest.java | 45 + .../cob/CobPartitioningTest.java | 98 +- .../common/FineractFeignClientHelper.java | 58 + settings.gradle | 1 + 56 files changed, 7908 insertions(+), 23 deletions(-) create mode 100644 fineract-client-feign/.openapi-generator-ignore create mode 100644 fineract-client-feign/build.gradle create mode 100644 fineract-client-feign/dependencies.gradle create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/adapter/ExternalIdAdapter.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/BasicAuthRequestInterceptor.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FeignException.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractErrorDecoder.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClient.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractFeignClientConfig.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/FineractMultipartEncoder.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/ObjectMapperFactory.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/TenantIdRequestInterceptor.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/DocumentsApiFixed.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/ImagesApi.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/services/RunReportsApi.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/CallFailedRuntimeException.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/feign/util/FeignCalls.java create mode 100644 fineract-client-feign/src/main/java/org/apache/fineract/client/util/FeignParts.java create mode 100644 fineract-client-feign/src/main/resources/templates/java/api.mustache create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/adapter/ExternalIdAdapterTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FeignExceptionTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractErrorDecoderTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/FineractFeignClientConfigTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/ObjectMapperFactoryTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/EncoderDecoderIntegrationTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/integration/FineractFeignClientIntegrationTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/feign/performance/ConnectionPoolPerformanceTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/services/DocumentsApiFixedIntegrationTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/services/ImagesApiIntegrationTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/services/RunReportsApiIntegrationTest.java create mode 100644 fineract-client-feign/src/test/java/org/apache/fineract/client/util/FeignPartsTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignClientSmokeTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignDocumentTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignImageTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/FeignIntegrationTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/FeignLoanTestBase.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignAccountHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignBusinessDateHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignClientHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignJournalEntryHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignLoanHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/FeignTransactionHelper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanProductTemplates.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanRequestBuilders.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestAccounts.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestData.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/LoanTestValidators.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanCreationTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignLoanTestBaseSmokeTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/common/FineractFeignClientHelper.java diff --git a/build.gradle b/build.gradle index 0d7d4af5d37..1d25ed0514e 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 00000000000..67b0c43e13d --- /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 00000000000..c158b75aa68 --- /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 00000000000..59cb8278b0b --- /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 00000000000..00bd2f0ee0c --- /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 { + + @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 { + + @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 00000000000..dad772522d8 --- /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 00000000000..a80430c00b2 --- /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 00000000000..6c5525983f6 --- /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 00000000000..8c049039f82 --- /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. + *

+ * Example usage: + * + *

+ * {@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 clients = clientsApi.retrieveAll();
+ * }
+ * 
+ */ +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 + * The API interface type + * @param apiType + * The API interface class + * @return A configured Feign client for the specified API + */ + public T create(Class 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 00000000000..04fe8e1ef84 --- /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 createClient(Class 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 00000000000..7f816790025 --- /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 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 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 00000000000..18e163041e7 --- /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 00000000000..15578cd3ec2 --- /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 00000000000..de3f7934a03 --- /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 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 00000000000..6fa311e8dab --- /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 FINERACT-1222), but fixed for + * bugs in the code generation (see FINERACT-1227). + * + * 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 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 00000000000..dfcaa298b6f --- /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 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 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 00000000000..c126899bb39 --- /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 00000000000..950c459c461 --- /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 ok(Supplier 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 execute(Supplier 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 00000000000..cd7cd80bf3a --- /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 fileName(Response response) { + if (response.headers() == null) { + return Optional.empty(); + } + + java.util.Collection 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 00000000000..44edc0a3b79 --- /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 queryParams + {{/hasQueryParams}} + {{#hasHeaderParams}} + {{#hasPathParams}}{{^bodyParam}}{{^hasQueryParams}},{{/hasQueryParams}}{{/bodyParam}}{{/hasPathParams}} + {{#bodyParam}}{{^hasQueryParams}},{{/hasQueryParams}}{{/bodyParam}} + {{#hasQueryParams}},{{/hasQueryParams}} + @HeaderMap Map 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 00000000000..0840a1aa2c2 --- /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 00000000000..a61d5e0f1ed --- /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 00000000000..59d580fe887 --- /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> 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 00000000000..d2de297de5f --- /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 00000000000..237ee4779fd --- /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 00000000000..e486de21b54 --- /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 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 items; + private Map 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 getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map 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 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 getItems() { + return items; + } + + public void setItems(List 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 00000000000..290428028b9 --- /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 00000000000..74a3121abc5 --- /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> futures = new ArrayList<>(); + + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < numRequests; i++) { + futures.add(executor.submit(api::getTest)); + } + + for (Future 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> 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 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> 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 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 00000000000..d19703607ac --- /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 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 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 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 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 00000000000..94c5de229fd --- /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 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 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 00000000000..528cc7600ca --- /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 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 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 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 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 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 00000000000..c095cb9fe59 --- /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 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 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 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 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 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 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 943703ead3a..590942545dd 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 getAuthorities() { return null; diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index 1861b2ed128..3aed9915fa3 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 66f45bb7443..9b0cb8cafe8 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 00000000000..597f05d654d --- /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 00000000000..77241fd7a19 --- /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 00000000000..22c7c22a0f9 --- /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 00000000000..593601a0d26 --- /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 ok(Supplier 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 ObjectAssert 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 OptionalAssert assertThat(Optional 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 00000000000..0a4155ca8a6 --- /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 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 00000000000..4dc91104c15 --- /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 00000000000..88038af59f1 --- /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 00000000000..3c2f806f4bb --- /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 00000000000..3f1416006bd --- /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 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 00000000000..cff401185e0 --- /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 00000000000..7356844e2b0 --- /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 00000000000..95cdedb1f95 --- /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 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 00000000000..ead6e9140ca --- /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 00000000000..bed8fb56404 --- /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 00000000000..0b42a26f9fe --- /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 00000000000..211661b2cc8 --- /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 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 00000000000..06ec3d49487 --- /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 00000000000..02fd158502e --- /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 c511e303b13..7d054cea699 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.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 static void setupInvestorBusinessStep() { setProperFinancialActivity(TRANSFER_ACCOUNT); } + @AfterEach + public void tearDown() { + // Restore business date configuration after each test + cleanUpAndRestoreBusinessDate(); + } + private static void setProperFinancialActivity(Account transferAccount) { List financialMappings = FINANCIAL_ACTIVITY_ACCOUNT_HELPER.getAllFinancialActivityAccounts(); financialMappings.forEach(mapping -> FINANCIAL_ACTIVITY_ACCOUNT_HELPER.deleteFinancialActivityAccount(mapping.getId())); @@ -144,13 +151,16 @@ public void testLoanCOBPartitioningQuery() throws InterruptedException { // Force close loans 3, 4, ... , N-3, N-2 Collections.sort(loanIds); + List 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 void testLoanCOBPartitioningQuery() throws InterruptedException { }); closeLatch.await(); - // Let's retrieve the partitions - List> 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 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> 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> 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 @@ private HashMap collaterals(Integer collateralId, BigDecimal qua 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> filterPartitionsForTestLoans(List> allPartitions, List testLoanIds) { + List> filteredPartitions = new ArrayList<>(); + + for (Map 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 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 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 00000000000..d909989db0b --- /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 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 de7530c1a1c..c8cf0e47ae0 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' From ffa1a974a22a7e0b81acbb6c3918e96de189bc5b Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Thu, 16 Oct 2025 19:00:12 +0200 Subject: [PATCH 03/36] FINERACT-2389: Fix related installment query --- .../apache/fineract/portfolio/loanaccount/domain/Loan.java | 4 +++- .../loanaccount/domain/LoanRepaymentScheduleInstallment.java | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 03b1794faf1..0054f3de3e6 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -1411,7 +1411,9 @@ public void addLoanRepaymentScheduleInstallment(final LoanRepaymentScheduleInsta * @return a schedule installment is related to the provided date **/ public LoanRepaymentScheduleInstallment getRelatedRepaymentScheduleInstallment(LocalDate date) { - return getRepaymentScheduleInstallment(e -> DateUtils.isDateInRangeFromExclusiveToInclusive(date, e.getFromDate(), e.getDueDate())); + return getRepaymentScheduleInstallment( + e -> (e.isFirstNormalInstallment() && DateUtils.isDateInRangeInclusive(date, e.getFromDate(), e.getDueDate())) + || DateUtils.isDateInRangeFromExclusiveToInclusive(date, e.getFromDate(), e.getDueDate())); } public LoanRepaymentScheduleInstallment fetchRepaymentScheduleInstallment(final Integer installmentNumber) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java index 94d6dd31511..52e8dd89825 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleInstallment.java @@ -1215,4 +1215,9 @@ private BigDecimal setScaleAndDefaultToNullIfZero(final BigDecimal value) { } return value.setScale(6, MoneyHelper.getRoundingMode()); } + + public boolean isFirstNormalInstallment() { + return loan.getRepaymentScheduleInstallments().stream().filter(rp -> !rp.isDownPayment()).findFirst().stream() + .anyMatch(rp -> rp.equals(this)); + } } From 834b653291652d1f342b5326926d665235bf5ce8 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Fri, 17 Oct 2025 18:37:55 +0200 Subject: [PATCH 04/36] FINERACT-2389: added e2e test to validate loan rescheduling on the first day of 1st repayment schedule --- .../resources/features/LoanReschedule.feature | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature index a0fce1a0dfd..2587c0638ea 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanReschedule.feature @@ -1089,4 +1089,48 @@ Feature: LoanReschedule | 01 February 2024 | Repayment | 17.16 | 16.33 | 0.83 | 0.0 | 0.0 | 83.67 | false | false | | 15 February 2024 | Repayment | 8.58 | 8.58 | 0.0 | 0.0 | 0.0 | 75.09 | false | false | + @TestRailId:C4126 + Scenario: Verify rescheduling of progressive loan is allowed on the first day of 1st repayment schedule + When Admin sets the business date to "24 July 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30 | 24 July 2025 | 500 | 35 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "24 July 2025" with "500" amount and expected disbursement date on "24 July 2025" + When Admin successfully disburse the loan on "24 July 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 24 July 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 24 August 2025 | | 422.54 | 77.46 | 14.58 | 0.0 | 0.0 | 92.04 | 0.0 | 0.0 | 0.0 | 92.04 | + | 2 | 31 | 24 September 2025 | | 342.82 | 79.72 | 12.32 | 0.0 | 0.0 | 92.04 | 0.0 | 0.0 | 0.0 | 92.04 | + | 3 | 30 | 24 October 2025 | | 260.78 | 82.04 | 10.0 | 0.0 | 0.0 | 92.04 | 0.0 | 0.0 | 0.0 | 92.04 | + | 4 | 31 | 24 November 2025 | | 176.35 | 84.43 | 7.61 | 0.0 | 0.0 | 92.04 | 0.0 | 0.0 | 0.0 | 92.04 | + | 5 | 30 | 24 December 2025 | | 89.45 | 86.9 | 5.14 | 0.0 | 0.0 | 92.04 | 0.0 | 0.0 | 0.0 | 92.04 | + | 6 | 31 | 24 January 2026 | | 0.0 | 89.45 | 2.61 | 0.0 | 0.0 | 92.06 | 0.0 | 0.0 | 0.0 | 92.06 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 500.0 | 52.26 | 0.0 | 0.0 | 552.26 | 0.0 | 0.0 | 0.0 | 552.26 | + And Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 24 July 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 500.0 | false | false | + When Admin creates and approves Loan reschedule with the following data: + | rescheduleFromDate | submittedOnDate | adjustedDueDate | graceOnPrincipal | graceOnInterest | extraTerms | newInterestRate | + | 24 July 2025 | 24 July 2025 | | | | | 5 | + Then Loan Repayment schedule has 6 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 24 July 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 24 August 2025 | | 417.53 | 82.47 | 2.08 | 0.0 | 0.0 | 84.55 | 0.0 | 0.0 | 0.0 | 84.55 | + | 2 | 31 | 24 September 2025 | | 334.72 | 82.81 | 1.74 | 0.0 | 0.0 | 84.55 | 0.0 | 0.0 | 0.0 | 84.55 | + | 3 | 30 | 24 October 2025 | | 251.56 | 83.16 | 1.39 | 0.0 | 0.0 | 84.55 | 0.0 | 0.0 | 0.0 | 84.55 | + | 4 | 31 | 24 November 2025 | | 168.06 | 83.5 | 1.05 | 0.0 | 0.0 | 84.55 | 0.0 | 0.0 | 0.0 | 84.55 | + | 5 | 30 | 24 December 2025 | | 84.21 | 83.85 | 0.7 | 0.0 | 0.0 | 84.55 | 0.0 | 0.0 | 0.0 | 84.55 | + | 6 | 31 | 24 January 2026 | | 0.0 | 84.21 | 0.35 | 0.0 | 0.0 | 84.56 | 0.0 | 0.0 | 0.0 | 84.56 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 500.0 | 7.31 | 0.0 | 0.0 | 507.31 | 0.0 | 0.0 | 0.0 | 507.31 | + And Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 24 July 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 500.0 | false | false | + Then LoanRescheduledDueAdjustScheduleBusinessEvent is raised on "24 July 2025" + From e4a8a8c93eb8063ce960d1a641d3ca37d3777b42 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Mon, 6 Oct 2025 15:23:34 -0700 Subject: [PATCH 05/36] improve release process kickoff email template this is shorter & sweeter --- .../main/resources/email/release.step01.headsup.message.ftl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/buildSrc/src/main/resources/email/release.step01.headsup.message.ftl b/buildSrc/src/main/resources/email/release.step01.headsup.message.ftl index 379f06d74a0..dbebd931f8c 100644 --- a/buildSrc/src/main/resources/email/release.step01.headsup.message.ftl +++ b/buildSrc/src/main/resources/email/release.step01.headsup.message.ftl @@ -20,9 +20,7 @@ --> Hello everyone, -... based on our "How to Release Apache Fineract" process (https://cwiki.apache.org/confluence/x/DRwIB) as well as the "Releases" chapter in the docs (https://fineract.apache.org/docs/current/#_releases), - -I will create a ${project['fineract.release.version']} branch off develop in our git repository at https://github.com/apache/fineract on ${project['fineract.releaseBranch.date']}. +... based on our release process (https://fineract.apache.org/docs/current/#_releases), I will create a release/${project['fineract.release.version']} branch off develop in our git repository at https://github.com/apache/fineract on ${project['fineract.releaseBranch.date']}. The release tracking umbrella issue for tracking all activity in JIRA is FINERACT-${project['fineract.release.issue']!'0000'} (https://issues.apache.org/jira/browse/FINERACT-${project['fineract.release.issue']!'0000'}). From ca5b820c7c140a34164c6d2b5fa5c3c0fdd8475b Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Tue, 7 Oct 2025 11:15:51 -0700 Subject: [PATCH 06/36] improve release branch email template * fix typos * simplify * adapt to how I use this today, including explicit branch name --- .../resources/email/release.step03.branch.message.ftl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/buildSrc/src/main/resources/email/release.step03.branch.message.ftl b/buildSrc/src/main/resources/email/release.step03.branch.message.ftl index a6f3667b25a..bcc15a15f6e 100644 --- a/buildSrc/src/main/resources/email/release.step03.branch.message.ftl +++ b/buildSrc/src/main/resources/email/release.step03.branch.message.ftl @@ -20,13 +20,13 @@ --> Hello everyone, -... as previously announced, I've just created the release branch for our upcoming ${project['fineract.release.version']} release. +... as previously announced, I've created the branch for our upcoming ${project['fineract.release.version']} release. The branch name is release/${project['fineract.release.version']}. -You can continue working and merging PRs to the develop branch for future releases, as always. +You can continue working and merging PRs into the develop branch for future releases, as always. -The DRAFT release notes are on https://cwiki.apache.org/confluence/display/FINERACT/${project['fineract.release.version']}+-+Apache+Fineract . Does anyone see anything missing? +I started the DRAFT release notes at https://cwiki.apache.org/confluence/display/FINERACT/${project['fineract.release.version']}+-+Apache+Fineract . Please help me by filling in "Summary of changes". Does anyone see anything else missing? -Does anyone have any last minutes changes they would like to see cherry-picked to branch ${project['fineract.release.version']}, or are we good go and actually cut the release based on this branch as it is? +Does anyone have any last minute changes for the release branch, or are we good to go and actually cut the release based on this branch as it is? I'll initiate the final stage of actually creating the release on ${project['fineract.release.date']} if nobody objects. From 8e4ac2cbb53be077b3defc3a66ce5053bbc23902 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Thu, 9 Oct 2025 09:57:40 -0700 Subject: [PATCH 07/36] clean during artifact build release step 6 I think the `clean` task is important here, Felix and I were seeing issues with jar filenames with incorrect version numbers. His work on FINERACT-2341 may have fixed whatever was causing it, but let's also/still do the `clean` before building release artifacts / candidates just to be safe. --- .../src/docs/en/chapters/release/process-step06.adoc | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc index c480f78e384..2e669f72228 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc @@ -4,17 +4,15 @@ Create source and binary tarballs. -// FIXME - clean this up? focus on what commands should actually be run - [source,bash,subs="attributes+"] ---- -./gradlew --rerun-tasks srcDistTar binaryDistTar <1> +./gradlew clean +./gradlew srcDistTar binaryDistTar ---- -<1> The source tarball might not be created if `--rerun-tasks` is omitted. Look in `fineract-war/build/distributions/` for the tarballs. -Make sure to do some sanity checks. The source tarball and the code in the release branch (at the commit with the release tag) should match. +Do some sanity checks. The source tarball and the code in the release branch (at the commit with the release tag) should match. [source,bash,subs="attributes+"] ---- @@ -27,8 +25,6 @@ cd .. diff -r fineract apache-fineract-src-{revnumber} ---- -// FIXME - add output example - Make sure the code compiles and tests pass on the uncompressed source. Do as much testing as you can and share what you did. Here's the bare minimum check: [source,bash,subs="attributes+"] From aaee438420ec21ee839a342e9c07a2d4636b5a83 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Thu, 9 Oct 2025 10:25:47 -0700 Subject: [PATCH 08/36] doc: remove errant config but keep :sectlinks: See 89e9cfcd7dd30662e31afd63dac2b4b6e39912bc I think this was a typo. It's presumably changing the highlighter midstream and causing section numbering to start at the "Fineract Development Environment" chapter. But I really like `:sectlinks:` and I want to keep that! It's handy for sharing deep / sorta permanent links to specific sections (just click on any heading and copy the URL). --- .../en/chapters/features/journal-entry-aggregation.adoc | 7 ------- fineract-doc/src/docs/en/config.adoc | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/features/journal-entry-aggregation.adoc b/fineract-doc/src/docs/en/chapters/features/journal-entry-aggregation.adoc index 256ab5d77f9..f44fbaf7f69 100644 --- a/fineract-doc/src/docs/en/chapters/features/journal-entry-aggregation.adoc +++ b/fineract-doc/src/docs/en/chapters/features/journal-entry-aggregation.adoc @@ -1,11 +1,4 @@ = Journal Entry Aggregation -:experimental: -:source-highlighter: highlightjs -:toc: left -:toclevels: 3 -:icons: font -:sectlinks: -:sectnums: == Overview The Journal Entry Aggregation Job is a Spring Batch-based solution designed to efficiently aggregate journal entries in the Fineract system. This job processes journal entries in configurable chunks, improving performance and resource utilization when dealing with large volumes of financial transactions. diff --git a/fineract-doc/src/docs/en/config.adoc b/fineract-doc/src/docs/en/config.adoc index 1bc515b5f83..b6543f8dc1e 100644 --- a/fineract-doc/src/docs/en/config.adoc +++ b/fineract-doc/src/docs/en/config.adoc @@ -20,6 +20,7 @@ :checkedbox: pass:normal[{startsb}✔{endsb}] :table-stripes: even :hardbreaks: +:sectlinks: // ifeval::["{draft}"=="true"] // :title-page-background-image: image:{commondir}/images/draft.svg[position=top] From 29d02e0c93077b4b823bcc7a8da19eb7cb160961 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Thu, 9 Oct 2025 10:58:08 -0700 Subject: [PATCH 09/36] improve staging doc and commands in release step 8 --- .../src/docs/en/chapters/release/process-step08.adoc | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step08.adoc b/fineract-doc/src/docs/en/chapters/release/process-step08.adoc index 05a73770bb8..f97ba4fecc8 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step08.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step08.adoc @@ -2,7 +2,7 @@ == Description -Finally create a directory with release name ({revnumber} in this example) in https://dist.apache.org/repos/dist/dev/fineract and add the following files in this new directory: +Next we'll stage the release candidate. Create a new folder and add these files: * apache-fineract-bin-{revnumber}.tar.gz * apache-fineract-bin-{revnumber}.tar.gz.sha512 @@ -11,16 +11,18 @@ Finally create a directory with release name ({revnumber} in this example) in ht * apache-fineract-src-{revnumber}.tar.gz.sha512 * apache-fineract-src-{revnumber}.tar.gz.asc -These files (or "artifacts") make up the release candidate. - -Upload these files to ASF's distribution dev (staging) area: +These files (or "artifacts") comprise the release candidate. Upload these files to https://www.apache.org/legal/release-policy.html#stage[ASF's distribution dev/staging area] like so: [source,bash,subs="attributes+"] ---- +# this is a remote operation svn mkdir https://dist.apache.org/repos/dist/dev/fineract/{revnumber} +# create local svn-tracked folder "{revnumber}" svn checkout https://dist.apache.org/repos/dist/dev/fineract/{revnumber} -cp path/to/files/* {revnumber}/ +# prepare to upload +cp path/to/new/folder/* {revnumber}/ cd {revnumber}/ +# actual upload occurs here svn add * && svn commit ---- From 6e327f7dbf005802bef4149e9e5c462ec99573da Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Fri, 10 Oct 2025 14:33:47 -0700 Subject: [PATCH 10/36] release step 6: fix sanity check example --- fineract-doc/src/docs/en/chapters/release/process-step06.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc index 2e669f72228..a32093eebac 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc @@ -17,7 +17,7 @@ Do some sanity checks. The source tarball and the code in the release branch (at [source,bash,subs="attributes+"] ---- cd /fineract-release-preparations -tar -xvf path/to/apache-fineract-src-{revnumber}.tar.gz +tar -xvzf path/to/apache-fineract-src-{revnumber}.tar.gz git clone git@github.com:apache/fineract.git cd fineract/ git checkout tags/{revnumber} From a0617870f70f8be3372175cb981b58cedc99dd91 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Sat, 11 Oct 2025 13:35:16 -0700 Subject: [PATCH 11/36] further improve release steps 6 and 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originally I thought I was causing issues by running `./gradlew clean` separate from `./gradlew srcDistTar binaryDistTar`. If I did (or so I thought), a `fineract-provider/build/classes/java/main/git.properties` file was _sometimes_ not generated (the `generateGitProperties` task failing, misbehaving, or not even running? was the file getting deleted? no idea). Unsure of the cause, at least I understand that file is supposed to end up at `BOOT-INF/classes/git.properties` in `fineract-provider-VERSION.jar` in the binary release tarball. Its contents are displayed at the `/fineract-provider/actuator/info` endpoint. I think that's more or less how it works, but I did not spend enough time on this to be certain. Now I think it wasn't at all related to how I was running our Gradle tasks, but rather some nasty behavior in gradle-git-properties: * https://github.com/n0mer/gradle-git-properties/issues/233 * https://github.com/gradle/gradle/issues/34177 Yuck. 🤢 --- .../en/chapters/release/process-step06.adoc | 14 +++----- .../en/chapters/release/process-step09.adoc | 35 +++++++++++++------ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc index a32093eebac..ed970fd5be0 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc @@ -6,10 +6,11 @@ Create source and binary tarballs. [source,bash,subs="attributes+"] ---- -./gradlew clean -./gradlew srcDistTar binaryDistTar +./gradlew clean srcDistTar binaryDistTar ---- +Check that `fineract-provider/build/classes/java/main/git.properties` exists. If so, continue. If not, you're likely encountering https://github.com/n0mer/gradle-git-properties/issues/233[this bug], and you need to re-run the command above to create proper source and binary tarballs. That `git.properties` file is supposed to end up at `BOOT-INF/classes/git.properties` in `fineract-provider-{revnumber}.jar` in the binary release tarball. Its contents are displayed at the `/fineract-provider/actuator/info` endpoint. It may be possible to fix this heisenbug entirely by https://github.com/gradle/gradle/issues/34177#issuecomment-3051970053[modifying our git properties gradle plugin config] in `fineract-provider/build.gradle`, perhaps by changing where `git.properties` is written. + Look in `fineract-war/build/distributions/` for the tarballs. Do some sanity checks. The source tarball and the code in the release branch (at the commit with the release tag) should match. @@ -25,17 +26,10 @@ cd .. diff -r fineract apache-fineract-src-{revnumber} ---- -Make sure the code compiles and tests pass on the uncompressed source. Do as much testing as you can and share what you did. Here's the bare minimum check: - -[source,bash,subs="attributes+"] ----- -./gradlew build -x test -x doc ----- +Make sure the code compiles and tests pass on the uncompressed source. You should at the very least do exactly what you will ask the community to do in <>. Ideally you'd build code and docs and run every possible test and check, but https://github.com/apache/fineract/actions[running everything has complex dependencies, caches, and takes many hours]. It is rarely done in practice offline / local / on developer machines. But please, go ahead and run the test and doc tasks, and more! Grab a cup of coffee and run everything you can. See the various builds in `.github/workflows/` and try the same things on your own. We should all hammer on a release candidate as much as we can to see if it breaks and fix it if so. All that of course improves our final release. -Finally, inspect `apache-fineract-bin-{revnumber}.tar.gz`. Make sure the `fineract-provider-{revnumber}.jar` can be run directly, and the `fineract-provider.war` can be run with Tomcat. - NOTE: We don't release any artifacts to Apache's Maven repository. == Gradle Task diff --git a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc index e532f3ad7bf..4c394851352 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc @@ -21,8 +21,7 @@ Make sure release artifacts are hosted at https://dist.apache.org/repos/dist/dev ---- # source tarball signature and checksum verification steps # we'll check the source tarball first -version={revnumber} -src=apache-fineract-src-$version.tar.gz +src=apache-fineract-src-{revnumber}.tar.gz # upon success: prints "Good signature" and returns successful exit code # upon failure: prints "BAD signature" and returns error exit code @@ -33,7 +32,7 @@ gpg --verify $src.asc gpg --print-md SHA512 $src | diff - $src.sha512 # binary tarball signature and checksum verification steps and outputs are similar -bin=apache-fineract-bin-$version.tar.gz +bin=apache-fineract-bin-{revnumber}.tar.gz gpg --verify $bin.asc gpg --print-md SHA512 $bin | diff - $bin.sha512 ---- @@ -48,28 +47,44 @@ TIP: Consider also https://en.wikipedia.org/wiki/Key_signing_party[signing] and === Build from source -[source,bash] +[source,bash,subs="attributes+"] ---- tar -xzf $src -cd apache-fineract-src-$version -gradle build -x test -x doc +cd apache-fineract-src-{revnumber} +./gradlew build -x test -x doc cd .. ---- === Run from binary -Before running this you must first start a database server and ensure the `fineract_default` and `fineract_tenant` databases exist. Then: +Before running Fineract you must first start a <> and ensure the `fineract_default` and `fineract_tenants` databases exist. Detailed steps for database preparation are left as an exercise for the reader. You can find ideas on how to do prepare your database in the `build-mariadb.yml`, `build-mysql.yml`, and `build-postgresql.yml` files in source control. + +Finally, start your Fineract server: + +[source,bash,subs="attributes+"] +---- +tar -xvzf apache-fineract-bin-{revnumber} +cd apache-fineract-bin-{revnumber} +export FINERACT_SERVER_SSL_ENABLED=false +export FINERACT_SERVER_PORT=8080 +export BACKEND_PROTOCOL=http +export BACKEND_PORT=$FINERACT_SERVER_PORT +# assumes reachable, healthy mariadb with default username, password, and port +java -jar fineract-provider-{revnumber}.jar +---- + +Alternatively, you can run it in Tomcat: -[source,bash] +[source,bash,subs="attributes+"] ---- -tar -xzf $bin -cd apache-fineract-bin-$version cat << 'EndOfRcenv' >> rcenv FINERACT_SERVER_SSL_ENABLED=false FINERACT_SERVER_PORT=8080 BACKEND_PROTOCOL=http BACKEND_PORT=$FINERACT_SERVER_PORT EndOfRcenv +source rcenv +# assumes reachable, healthy mariadb with default username, password, and port docker run --rm -it -v "$(pwd):/usr/local/tomcat/webapps" \ --net=host --env-file=rcenv tomcat:jre21 ---- From e24aa27994d217e3f5cec33cf9afb312b2039368 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Sat, 11 Oct 2025 14:08:30 -0700 Subject: [PATCH 12/36] cucumber.adoc: fix list continuations Fixes: ``` > Task :fineract-doc:asciidoctorPdf chapters/testing/cucumber.adoc: line 409: list item index: expected 1, got 3 chapters/testing/cucumber.adoc: line 411: list item index: expected 2, got 4 chapters/testing/cucumber.adoc: line 418: list item index: expected 1, got 5 ``` See https://docs.asciidoctor.org/asciidoc/latest/lists/continuation/#list-continuation --- fineract-doc/src/docs/en/chapters/testing/cucumber.adoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fineract-doc/src/docs/en/chapters/testing/cucumber.adoc b/fineract-doc/src/docs/en/chapters/testing/cucumber.adoc index 40b63cf9005..e9e876525c4 100644 --- a/fineract-doc/src/docs/en/chapters/testing/cucumber.adoc +++ b/fineract-doc/src/docs/en/chapters/testing/cucumber.adoc @@ -393,6 +393,7 @@ When writing new Cucumber tests: 1. *Create Feature File*: Add new `.feature` file in `fineract-e2e-tests-runner/src/test/resources/features/` 2. *Use Gherkin Syntax*: ++ [source,gherkin] ---- Feature: Loan Disbursement @@ -409,6 +410,7 @@ Feature: Loan Disbursement 3. *Implement Step Definitions*: Add corresponding step definitions in `fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/` 4. *Add Tags*: Tag scenarios appropriately: ++ [source,gherkin] ---- @TestRailId:C1234 @Smoke @@ -416,6 +418,7 @@ Scenario: Critical loan test ---- 5. *Verify with Gradle*: ++ [source,bash] ---- cd fineract-e2e-tests-runner From 78fee129ddd648c18fe3e13ab6b0d2e799f16cc9 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Sat, 11 Oct 2025 17:00:19 -0700 Subject: [PATCH 13/36] =?UTF-8?q?fix=20tarball=20explosion=20in=20release?= =?UTF-8?q?=20step=209=20=F0=9F=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fineract-doc/src/docs/en/chapters/release/process-step09.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc index 4c394851352..fa008024569 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc @@ -63,7 +63,7 @@ Finally, start your Fineract server: [source,bash,subs="attributes+"] ---- -tar -xvzf apache-fineract-bin-{revnumber} +tar -xvzf apache-fineract-bin-{revnumber}.tar.gz cd apache-fineract-bin-{revnumber} export FINERACT_SERVER_SSL_ENABLED=false export FINERACT_SERVER_PORT=8080 From 013bfad43405010fa766861c9f6ce3d60707022e Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Mon, 13 Oct 2025 09:05:07 -0700 Subject: [PATCH 14/36] improve step 9 artifact verification gpg tips --- .../en/chapters/release/process-step09.adoc | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc index fa008024569..85a63088a40 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc @@ -37,13 +37,30 @@ gpg --verify $bin.asc gpg --print-md SHA512 $bin | diff - $bin.sha512 ---- -For folks new to https://www.gnupg.org/[GnuPG], there are a couple things to note. First, if it says the source or binary tarball detached signature is correct, that's great! That's the most important part. +Look for `Good signature` in the `gpg` output: -Second, if you've imported `KEYS` but gpg warns you the key used for signing is not trusted, you can tell gpg you trust the key to squelch the warning. Ideally you meet the alleged key owner in person and check their ID first. Once you trust their identity matches, you then indicate your trust for their key. +[source,text,subs="attributes+"] +---- +$ gpg --verify $src.asc +gpg: assuming signed data in 'apache-fineract-bin-{revnumber}.tar.gz' +gpg: Signature made Sat 11 Oct 2025 05:46:42 PM PDT +gpg: using EDDSA key 250775BDB5FE7D53E4AF95C00E895A1A7A090CFC +gpg: Good signature from "Adam Monsen " [unknown] +---- + +That's the most important part. + +You may see this warning: + +[source,text] +---- +gpg: WARNING: This key is not certified with a trusted signature! +gpg: There is no indication that the signature belongs to the owner. +---- -Start with `gpg --edit-key KEYID`, substituting the signing key id for `KEYID`. At the `gpg>` prompt, run the `trust` command and choose `4` (I trust fully). You could also choose `3` (marginal), but do _not_ choose `5` (ultimate). +You may choose to ignore it. To squelch this warning, you must extend your https://en.wikipedia.org/wiki/Web_of_trust[web of trust], by, for example, https://en.wikipedia.org/wiki/Key_signing_party[signing the release manager's key]. -TIP: Consider also https://en.wikipedia.org/wiki/Key_signing_party[signing] and https://en.wikipedia.org/wiki/Web_of_trust[uploading] each other's keys. +Now it's time to build and run the release candidate. === Build from source From f469b2c8e30351849ff95f71a5e3e9cbf1388c4d Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Mon, 13 Oct 2025 09:15:53 -0700 Subject: [PATCH 15/36] fix typos in step 9 re: running the binary --- .../src/docs/en/chapters/release/process-step09.adoc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc index 85a63088a40..77fe3e95671 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc @@ -41,7 +41,7 @@ Look for `Good signature` in the `gpg` output: [source,text,subs="attributes+"] ---- -$ gpg --verify $src.asc +$ gpg --verify $bin.asc gpg: assuming signed data in 'apache-fineract-bin-{revnumber}.tar.gz' gpg: Signature made Sat 11 Oct 2025 05:46:42 PM PDT gpg: using EDDSA key 250775BDB5FE7D53E4AF95C00E895A1A7A090CFC @@ -74,7 +74,7 @@ cd .. === Run from binary -Before running Fineract you must first start a <> and ensure the `fineract_default` and `fineract_tenants` databases exist. Detailed steps for database preparation are left as an exercise for the reader. You can find ideas on how to do prepare your database in the `build-mariadb.yml`, `build-mysql.yml`, and `build-postgresql.yml` files in source control. +Before running Fineract you must first start a <> and ensure the `fineract_default` and `fineract_tenants` databases exist. Detailed steps for database preparation are left as an exercise for the reader. You can find ideas on how to prepare your database in the `build-mariadb.yml`, `build-mysql.yml`, and `build-postgresql.yml` files in source control. Finally, start your Fineract server: @@ -100,7 +100,6 @@ FINERACT_SERVER_PORT=8080 BACKEND_PROTOCOL=http BACKEND_PORT=$FINERACT_SERVER_PORT EndOfRcenv -source rcenv # assumes reachable, healthy mariadb with default username, password, and port docker run --rm -it -v "$(pwd):/usr/local/tomcat/webapps" \ --net=host --env-file=rcenv tomcat:jre21 From e9364909cbae27ae0b67005de429759a4fab12fc Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Mon, 13 Oct 2025 09:23:35 -0700 Subject: [PATCH 16/36] update link to rc verification instructions --- .../src/main/resources/email/release.step10.vote.message.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/resources/email/release.step10.vote.message.ftl b/buildSrc/src/main/resources/email/release.step10.vote.message.ftl index 8be7ff5d0e5..96d3fd38d11 100644 --- a/buildSrc/src/main/resources/email/release.step10.vote.message.ftl +++ b/buildSrc/src/main/resources/email/release.step10.vote.message.ftl @@ -42,7 +42,7 @@ Please indicate if you are a binding vote (member of the PMC). Note: PMC members Please also indicate with "Tested: YES/NO/PARTIAL" if you have locally built and/or tested these artifacts and/or a clone of the code checked out to the release commit, following the form: -Tested: YES ... Verified integrity and signatures of release artifacts locally, built from source, ran jar/war: Did everything mentioned in the current release candidate verification guidance ( https://lists.apache.org/thread/hym94pdy3nk9gjspkz4qonv2v15n5dpo ). If you did more than that, please specify. +Tested: YES ... Verified integrity and signatures of release artifacts locally, built from source, ran jar/war: Did everything mentioned in the current release candidate verification guidance ( https://fineract.apache.org/docs/rc/#_artifact_verification ). If you did more than that, please specify. Tested: NO ... No testing performed on release candidate, e.g. relying on testing performed by other contributors and/or output of GitHub Actions, while exercising my right to vote. From bea0c82018bff56ea171f8b54e4be183be7cd7f6 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Mon, 13 Oct 2025 09:37:30 -0700 Subject: [PATCH 17/36] shorten/simplify gpg key sending commands also: change <3> to match text above, omitting otherwise helpful 0x hex designation prefix --- .../src/docs/en/chapters/release/configuration-gpg.adoc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fineract-doc/src/docs/en/chapters/release/configuration-gpg.adoc b/fineract-doc/src/docs/en/chapters/release/configuration-gpg.adoc index 2678766d3f6..4774cce9a84 100644 --- a/fineract-doc/src/docs/en/chapters/release/configuration-gpg.adoc +++ b/fineract-doc/src/docs/en/chapters/release/configuration-gpg.adoc @@ -151,7 +151,7 @@ sub cv25519/4FGHIJ56 2022-04-16 [E] [expires: 2024-04-16] <6> + <2> GPG created a revocation certificate and its directory. If your private key is compromised, you need to use your revocation certificate to revoke your key. + -<3> The public key uses the Ed25519 ECC (Elliptic Curve Cryptography) algorithm and shows the expiration date of 16 Apr 2024. The public key ID `0x7890ABCD` matches the last 8 characters of key fingerprint. The `[SC]` indicates this key is used to sign (prove authorship) and certify (issue subkeys for encryption, signature and authentication operations). +<3> The public key uses the Ed25519 ECC (Elliptic Curve Cryptography) algorithm and shows the expiration date of 16 Apr 2024. The public key ID `7890ABCD` matches the last 8 characters of key fingerprint. The `[SC]` indicates this key is used to sign (prove authorship) and certify (issue subkeys for encryption, signature and authentication operations). <4> The key fingerprint (`ABCD EFGH IJKL MNOP QRST UVWX YZ12 3456 7890 ABCD`) is a hash of your public key. + <5> Your name and your email address are shown with information about the subkey. @@ -186,7 +186,7 @@ IMPORTANT: Please contact a PMC member to add your GPG public key in Fineract's + [source,bash] ---- -gpg --send-keys ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCD +gpg --send-keys 0xYZ1234567890ABCD ---- + Before doing this, make sure that your default keyserver is hkp://keyserver.ubuntu.com/. You can do this by changing the default keyserver in ~/.gnupg/dirmngr.conf: @@ -200,9 +200,7 @@ Alternatively you can provide the keyserver with the send command: + [source,bash] ---- -gpg \ - --keyserver 'hkp://keyserver.ubuntu.com:11371' \ - --send-keys ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCD +gpg --keyserver keyserver.ubuntu.com --send-keys 0xYZ1234567890ABCD ---- + Another option to publish your key is to submit an armored public key directly at https://keyserver.ubuntu.com/. You can create the necessary data with this command by providing the email address that you used when you created your key pair: From 1503037aeedc25df935ba0e828ba8cb5b6e3a803 Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Sun, 19 Oct 2025 09:50:06 -0700 Subject: [PATCH 18/36] save 1.13.0 vote result --- .../main/resources/vote/result.1.13.0.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 buildSrc/src/main/resources/vote/result.1.13.0.json diff --git a/buildSrc/src/main/resources/vote/result.1.13.0.json b/buildSrc/src/main/resources/vote/result.1.13.0.json new file mode 100644 index 00000000000..91650ad0205 --- /dev/null +++ b/buildSrc/src/main/resources/vote/result.1.13.0.json @@ -0,0 +1,56 @@ +{ + "approve": { + "binding": [ + { + "name": "James Dailey", + "email": "jdailey@apache.org" + }, + { + "name": "Victor Manuel Romero Rodriguez", + "email": "victor.romero@fintecheando.mx" + }, + { + "name": "Terence Monteiro", + "email": "terence.placker@gmail.com" + } + ], + "nonBinding": [ + { + "name": "Adam Monsen", + "email": "amonsen@mifos.org" + }, + { + "name": "Bharath Gowda", + "email": "bgowda@mifos.org" + }, + { + "name": "Aleksandar Vidakovic", + "email": "cheetah@monkeysintown.com" + }, + { + "name": "Ed Cable", + "email": "edcable@mifos.org" + }, + { + "name": "Felix van Hove", + "email": "fvanhove@gmx.de.invalid" + }, + { + "name": "Ahmed adel", + "email": "a7med3del1973@gmail.com" + } + ] + }, + "disapprove": { + "binding": [ + ], + "nonBinding": [ + ] + }, + "noOpinion": { + "binding": [ + ], + "nonBinding": [ + ] + } +} From b18e850decd6cba865c75f83aaf431896220091e Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Mon, 20 Oct 2025 08:05:08 -0700 Subject: [PATCH 19/36] don't include actual email addresses might help voters avoid a bit of spam --- .../resources/email/release.step11.vote.message.ftl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/buildSrc/src/main/resources/email/release.step11.vote.message.ftl b/buildSrc/src/main/resources/email/release.step11.vote.message.ftl index 232dd67f0ab..227d956d635 100644 --- a/buildSrc/src/main/resources/email/release.step11.vote.message.ftl +++ b/buildSrc/src/main/resources/email/release.step11.vote.message.ftl @@ -38,7 +38,7 @@ Here are the detailed results: <#list project['fineract.vote'].approve.binding> Binding +1s: <#items as item> -- ${item.name} (${item.email}) +- ${item.name} @@ -46,7 +46,7 @@ Binding +1s: <#list project['fineract.vote'].approve.nonBinding> Non binding +1s: <#items as item> -- ${item.name} (${item.email}) +- ${item.name} @@ -54,14 +54,14 @@ Non binding +1s: <#list project['fineract.vote'].disapprove.binding> Binding -1s: <#items as item> -- ${item.name} (${item.email}) +- ${item.name} <#list project['fineract.vote'].disapprove.nonBinding> Non binding -1s: <#items as item> -- ${item.name} (${item.email}) +- ${item.name} @@ -69,14 +69,14 @@ Non binding -1s: <#list project['fineract.vote'].noOpinion.binding> Binding +0s: <#items as item> -- ${item.name} (${item.email}) +- ${item.name} <#list project['fineract.vote'].noOpinion.nonBinding> Non binding +0s: <#items as item> -- ${item.name} (${item.email}) +- ${item.name} From e3096c7ef060484efcc5104e378a8bff17969f29 Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Tue, 21 Oct 2025 15:29:09 +0200 Subject: [PATCH 20/36] FINERACT-2396: Fix retry issue during persisting CommandSource --- .../service/SynchronousCommandProcessingService.java | 8 ++++---- .../service/SynchronousCommandProcessingServiceTest.java | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java index 43647f426a7..a13835b6266 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -166,13 +167,12 @@ private CommandProcessingResult executeCommand(final CommandWrapper wrapper, fin try { CommandSource finalCommandSource = commandSource; + AtomicInteger attemptNumber = new AtomicInteger(0); CommandSource savedCommandSource = persistenceRetry.executeSupplier(() -> { - // Get metrics for logging - long attemptNumber = persistenceRetry.getMetrics().getNumberOfFailedCallsWithRetryAttempt() + 1; - // Critical: Refetch on retry attempts (not on first attempt) CommandSource currentSource = finalCommandSource; - if (attemptNumber > 1) { + attemptNumber.getAndIncrement(); + if (attemptNumber.get() > 1 && commandSource.getId() != null) { log.info("Retrying command result save - attempt {} for command ID {}", attemptNumber, finalCommandSource.getId()); currentSource = commandSourceService.getCommandSource(finalCommandSource.getId()); } diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java index 4e71ae7954e..7bcb2b7d4f2 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java @@ -591,5 +591,11 @@ public void testExecuteCommandWithMaxRetryFailure() { assertEquals(persistentException, exception.getCause()); assertTrue(saveAttempts.get() >= 3, "Expected at least 3 save attempts, but got: " + saveAttempts.get()); + // First call was before saving CommandSource, then 2 calls during retry (1st retry does not try to fetch it) + verify(commandSourceService, times(3)).getCommandSource(commandId); + // Let test whether consecutive calls does not try to refetch immediately + when(commandSourceService.saveResultSameTransaction(any(CommandSource.class))).thenReturn(commandSource); + underTest.executeCommand(commandWrapper, jsonCommand, false); + verify(commandSourceService, times(4)).getCommandSource(commandId); } } From 54bc740738b37a10a361b75369c7a1d8dcc38bf8 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Tue, 21 Oct 2025 09:57:32 +0200 Subject: [PATCH 21/36] FINERACT-2380: feign related test fixes --- ...nerateLoanlossProvisioningTaskletTest.java | 3 +- .../cob/CobPartitioningTest.java | 98 +++---------------- 2 files changed, 17 insertions(+), 84 deletions(-) diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/jobs/generateloanlossprovisioning/GenerateLoanlossProvisioningTaskletTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/jobs/generateloanlossprovisioning/GenerateLoanlossProvisioningTaskletTest.java index 5d47f7bfcd9..4a43d1c068e 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/jobs/generateloanlossprovisioning/GenerateLoanlossProvisioningTaskletTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/jobs/generateloanlossprovisioning/GenerateLoanlossProvisioningTaskletTest.java @@ -68,7 +68,8 @@ class GenerateLoanlossProvisioningTaskletTest { @BeforeEach public void setUp() { - ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, BUSINESS_DATE))); + ThreadLocalContextUtil.setBusinessDates( + new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, BUSINESS_DATE, BusinessDateType.COB_DATE, BUSINESS_DATE))); } @Test 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 7d054cea699..c511e303b13 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,7 +59,6 @@ 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; @@ -107,12 +106,6 @@ public static void setupInvestorBusinessStep() { setProperFinancialActivity(TRANSFER_ACCOUNT); } - @AfterEach - public void tearDown() { - // Restore business date configuration after each test - cleanUpAndRestoreBusinessDate(); - } - private static void setProperFinancialActivity(Account transferAccount) { List financialMappings = FINANCIAL_ACTIVITY_ACCOUNT_HELPER.getAllFinancialActivityAccounts(); financialMappings.forEach(mapping -> FINANCIAL_ACTIVITY_ACCOUNT_HELPER.deleteFinancialActivityAccount(mapping.getId())); @@ -151,16 +144,13 @@ public void testLoanCOBPartitioningQuery() throws InterruptedException { // Force close loans 3, 4, ... , N-3, N-2 Collections.sort(loanIds); - List 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(); })); } @@ -173,45 +163,24 @@ public void testLoanCOBPartitioningQuery() throws InterruptedException { }); closeLatch.await(); - // Create list of active (non-foreclosed) loan IDs - List activeLoanIds = new ArrayList<>(loanIds); - activeLoanIds.removeAll(forelosedLoanIds); - log.info("Active loan IDs after foreclosure: {}", activeLoanIds); - // Let's retrieve the partitions - List> 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> 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"); + List> 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")); + + 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")); executorService.shutdown(); - } catch (Exception e) { - log.error("Test failed with exception", e); - throw e; + } finally { + cleanUpAndRestoreBusinessDate(); } } @@ -322,41 +291,4 @@ private HashMap collaterals(Integer collateralId, BigDecimal qua 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> filterPartitionsForTestLoans(List> allPartitions, List testLoanIds) { - List> filteredPartitions = new ArrayList<>(); - - for (Map 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 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 partition) { - Integer minId = (Integer) partition.get("minId"); - Integer maxId = (Integer) partition.get("maxId"); - return loanId >= minId && loanId <= maxId; - } - } From 40a044ecec413e8bd7a439048a08f856c8334e81 Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Mon, 13 Oct 2025 23:02:30 +0200 Subject: [PATCH 22/36] FINERACT-2326: fix delinquent days & delinquency date after delinquency pause calculations --- .../features/LoanDelinquency.feature | 15 +- .../DelinquencyEffectivePauseHelper.java | 3 + .../DelinquencyEffectivePauseHelperImpl.java | 24 + .../InstallmentDelinquencyAggregator.java | 87 +++ .../DelinquencyReadPlatformServiceImpl.java | 33 +- .../LoanDelinquencyDomainServiceImpl.java | 53 +- .../InstallmentDelinquencyAggregatorTest.java | 278 ++++++++++ .../LoanDelinquencyDomainServiceTest.java | 116 +++- .../DelinquencyActionIntegrationTests.java | 496 ++++++++++++++++++ .../DelinquencyBucketsIntegrationTest.java | 30 +- 10 files changed, 1061 insertions(+), 74 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature index 5967ea805c9..d12375f3714 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature @@ -610,6 +610,7 @@ Feature: LoanDelinquency # --- Grace period applied only on Loan level, not on installment level --- Then Loan has the following INSTALLMENT level delinquency data: | rangeId | Range | Amount | + | 1 | RANGE_1 | 250.00 | | 2 | RANGE_3 | 250.00 | @TestRailId:C3000 @@ -728,8 +729,7 @@ Feature: LoanDelinquency | RANGE_3 | 750.0 | 04 October 2023 | 30 | 43 | Then Loan has the following INSTALLMENT level delinquency data: | rangeId | Range | Amount | - | 1 | RANGE_1 | 250.00 | - | 2 | RANGE_3 | 250.00 | + | 2 | RANGE_3 | 500.00 | | 3 | RANGE_30 | 250.00 | # --- Second delinquency pause --- When Admin sets the business date to "14 November 2023" @@ -749,8 +749,7 @@ Feature: LoanDelinquency | RANGE_3 | 750.0 | 04 October 2023 | 31 | 44 | Then Loan has the following INSTALLMENT level delinquency data: | rangeId | Range | Amount | - | 1 | RANGE_1 | 250.00 | - | 2 | RANGE_3 | 250.00 | + | 2 | RANGE_3 | 500.00 | | 3 | RANGE_30 | 250.00 | Then Installment level delinquency event has correct data # --- Second delinquency ends --- @@ -770,8 +769,7 @@ Feature: LoanDelinquency | RANGE_3 | 1000.0 | 04 October 2023 | 31 | 60 | Then Loan has the following INSTALLMENT level delinquency data: | rangeId | Range | Amount | - | 1 | RANGE_1 | 250.00 | - | 2 | RANGE_3 | 250.00 | + | 2 | RANGE_3 | 500.00 | | 3 | RANGE_30 | 250.00 | # --- Delinquency runs again --- When Admin sets the business date to "01 December 2023" @@ -790,6 +788,7 @@ Feature: LoanDelinquency | RANGE_30 | 1000.0 | 04 October 2023 | 32 | 61 | Then Loan has the following INSTALLMENT level delinquency data: | rangeId | Range | Amount | + | 1 | RANGE_1 | 250.00 | | 2 | RANGE_3 | 500.00 | | 3 | RANGE_30 | 250.00 | Then Installment level delinquency event has correct data @@ -995,11 +994,11 @@ Feature: LoanDelinquency | RESUME | 25 October 2023 | | Then Loan has the following LOAN level delinquency data: | classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays | - | RANGE_3 | 500.0 | 19 October 2023 | 8 | 30 | + | RANGE_3 | 500.0 | 19 October 2023 | 18 | 30 | # --- Grace period applied only on Loan level, not on installment level --- Then Loan has the following INSTALLMENT level delinquency data: | rangeId | Range | Amount | - | 2 | RANGE_3 | 250.00 | + | 2 | RANGE_3 | 500.00 | Then Installment level delinquency event has correct data @TestRailId:C3013 diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java index e236d842225..a5722ff75c9 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelper.java @@ -28,4 +28,7 @@ public interface DelinquencyEffectivePauseHelper { List calculateEffectiveDelinquencyList(List savedDelinquencyActions); Long getPausedDaysBeforeDate(List effectiveDelinquencyList, LocalDate date); + + Long getPausedDaysWithinRange(List effectiveDelinquencyList, LocalDate startInclusive, + LocalDate endExclusive); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java index eeb64bff5dd..f400a836fdf 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/DelinquencyEffectivePauseHelperImpl.java @@ -66,6 +66,30 @@ public Long getPausedDaysBeforeDate(List effectiveDel return Long.sum(pausedDaysClosedPausePeriods, pausedDaysRunningPausePeriods); } + @Override + public Long getPausedDaysWithinRange(List effectiveDelinquencyList, LocalDate startInclusive, + LocalDate endExclusive) { + if (startInclusive == null || endExclusive == null || !startInclusive.isBefore(endExclusive)) { + return 0L; + } + return effectiveDelinquencyList.stream().map(pausePeriod -> { + LocalDate pauseStart = pausePeriod.getStartDate(); + LocalDate pauseEnd = Optional.ofNullable(pausePeriod.getEndDate()).orElse(endExclusive); + if (pauseStart == null || !pauseStart.isBefore(endExclusive)) { + return 0L; + } + if (!pauseEnd.isAfter(startInclusive)) { + return 0L; + } + LocalDate overlapStart = pauseStart.isAfter(startInclusive) ? pauseStart : startInclusive; + LocalDate overlapEnd = pauseEnd.isBefore(endExclusive) ? pauseEnd : endExclusive; + if (!overlapStart.isBefore(overlapEnd)) { + return 0L; + } + return DateUtils.getDifferenceInDays(overlapStart, overlapEnd); + }).reduce(0L, Long::sum); + } + private Optional findMatchingResume(LoanDelinquencyAction pause, List resumes) { if (resumes != null && resumes.size() > 0) { for (LoanDelinquencyAction resume : resumes) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java new file mode 100644 index 00000000000..3ddcb36348f --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregator.java @@ -0,0 +1,87 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.helper; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData; +import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency; + +/** + * Static utility class for aggregating installment-level delinquency data. + * + * @see InstallmentLevelDelinquency + * @see LoanInstallmentDelinquencyTagData + */ +public final class InstallmentDelinquencyAggregator { + + private InstallmentDelinquencyAggregator() {} + + /** + * Aggregates installment-level delinquency data by rangeId and sorts by minimumAgeDays. + * + * This method performs two key operations: 1. Groups installments by delinquency rangeId and sums delinquentAmount + * for installments with the same rangeId 2. Sorts the aggregated results by minimumAgeDays in ascending order + * + * @param installmentData + * Collection of installment delinquency data to aggregate + * @return Sorted list of aggregated delinquency data, empty list if input is null or empty + */ + public static List aggregateAndSort(Collection installmentData) { + + if (installmentData == null || installmentData.isEmpty()) { + return List.of(); + } + + Collection aggregated = installmentData.stream().map(InstallmentLevelDelinquency::from) + .collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values() + .stream().map(opt -> opt.orElseThrow(() -> new IllegalStateException("Unexpected empty Optional in aggregation"))).toList(); + + return aggregated.stream().sorted(Comparator.comparing(ild -> Optional.ofNullable(ild.getMinimumAgeDays()).orElse(0))).toList(); + } + + /** + * Creates a custom collector that sums delinquent amounts while preserving range metadata. + * + * This collector uses the reducing operation to combine multiple InstallmentLevelDelinquency objects with the same + * rangeId. It preserves the range classification (rangeId, classification, minimumAgeDays, maximumAgeDays) while + * summing the delinquentAmount fields. + * + * Note: This uses the 1-argument reducing() variant which returns Optional to avoid the identity value bug that + * would cause amounts to be incorrectly doubled when aggregating single installments. + * + * @return Collector that combines InstallmentLevelDelinquency objects by summing amounts + */ + private static Collector> delinquentAmountSummingCollector() { + return Collectors.reducing((item1, item2) -> { + final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency(); + result.setRangeId(item1.getRangeId()); + result.setClassification(item1.getClassification()); + result.setMaximumAgeDays(item1.getMaximumAgeDays()); + result.setMinimumAgeDays(item1.getMinimumAgeDays()); + result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount())); + return result; + }); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java index 4ad69707b3d..6b9f27b1944 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/DelinquencyReadPlatformServiceImpl.java @@ -27,8 +27,6 @@ import java.util.Comparator; import java.util.List; import java.util.Optional; -import java.util.stream.Collector; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -49,6 +47,7 @@ import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyTagHistoryRepository; import org.apache.fineract.portfolio.delinquency.domain.LoanInstallmentDelinquencyTagRepository; import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper; +import org.apache.fineract.portfolio.delinquency.helper.InstallmentDelinquencyAggregator; import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyBucketMapper; import org.apache.fineract.portfolio.delinquency.mapper.DelinquencyRangeMapper; import org.apache.fineract.portfolio.delinquency.mapper.LoanDelinquencyTagMapper; @@ -260,36 +259,12 @@ private void addInstallmentLevelDelinquencyData(CollectionData collectionData, L Collection loanInstallmentDelinquencyTagData = retrieveLoanInstallmentsCurrentDelinquencyTag( loanId); if (loanInstallmentDelinquencyTagData != null && !loanInstallmentDelinquencyTagData.isEmpty()) { - - // installment level delinquency grouped by rangeId, and summed up the delinquent amount - Collection installmentLevelDelinquencies = loanInstallmentDelinquencyTagData.stream() - .map(InstallmentLevelDelinquency::from) - .collect(Collectors.groupingBy(InstallmentLevelDelinquency::getRangeId, delinquentAmountSummingCollector())).values(); - - // sort this based on minimum days, so ranges will be delivered in ascending order - List sorted = installmentLevelDelinquencies.stream().sorted((o1, o2) -> { - Integer first = Optional.ofNullable(o1.getMinimumAgeDays()).orElse(0); - Integer second = Optional.ofNullable(o2.getMinimumAgeDays()).orElse(0); - return first.compareTo(second); - }).toList(); - - collectionData.setInstallmentLevelDelinquency(sorted); + List aggregated = InstallmentDelinquencyAggregator + .aggregateAndSort(loanInstallmentDelinquencyTagData); + collectionData.setInstallmentLevelDelinquency(aggregated); } } - @NonNull - private static Collector delinquentAmountSummingCollector() { - return Collectors.reducing(new InstallmentLevelDelinquency(), (item1, item2) -> { - final InstallmentLevelDelinquency result = new InstallmentLevelDelinquency(); - result.setRangeId(Optional.ofNullable(item1.getRangeId()).orElse(item2.getRangeId())); - result.setClassification(Optional.ofNullable(item1.getClassification()).orElse(item2.getClassification())); - result.setMaximumAgeDays(Optional.ofNullable(item1.getMaximumAgeDays()).orElse(item2.getMaximumAgeDays())); - result.setMinimumAgeDays(Optional.ofNullable(item1.getMinimumAgeDays()).orElse(item2.getMinimumAgeDays())); - result.setDelinquentAmount(MathUtil.add(item1.getDelinquentAmount(), item2.getDelinquentAmount())); - return result; - }); - } - void enrichWithDelinquencyPausePeriodInfo(CollectionData collectionData, Collection effectiveDelinquencyList, LocalDate businessDate) { List result = effectiveDelinquencyList.stream() // diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java index dd8a2218e69..e4fc1eb187f 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/delinquency/service/LoanDelinquencyDomainServiceImpl.java @@ -119,14 +119,15 @@ public CollectionData getOverdueCollectionData(final Loan loan, final List 0) { - calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays); - } + calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate, + overdueSinceDateForCalculation); log.debug("Result: {}", collectionData); return collectionData; @@ -200,31 +198,22 @@ public LoanDelinquencyData getLoanDelinquencyData(final Loan loan, List 0) { - calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays); - } + calculateAndSetDelinquentDays(collectionData, overdueDays, graceDays, effectiveDelinquencyList, businessDate, + overdueSinceDateForCalculation); return new LoanDelinquencyData(collectionData, loanInstallmentsCollectionData); } - private void calculateDelinquentDays(List effectiveDelinquencyList, LocalDate businessDate, - CollectionData collectionData, Long delinquentDays) { - Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate); - Long calculatedDelinquentDays = delinquentDays - pausedDays; - collectionData.setDelinquentDays(calculatedDelinquentDays > 0 ? calculatedDelinquentDays : 0L); - } - private CollectionData getInstallmentOverdueCollectionData(final Loan loan, final LoanRepaymentScheduleInstallment installment, final List effectiveDelinquencyList, final List chargebackTransactions) { final LocalDate businessDate = DateUtils.getBusinessLocalDate(); @@ -248,6 +237,7 @@ private CollectionData getInstallmentOverdueCollectionData(final Loan loan, fina // Grace days are not considered for installment level delinquency calculation currently. long overdueDays = 0L; + LocalDate overdueSinceDateForCalculation = overdueSinceDate; if (overdueSinceDate != null) { overdueDays = DateUtils.getDifferenceInDays(overdueSinceDate, businessDate); if (overdueDays < 0) { @@ -257,11 +247,8 @@ private CollectionData getInstallmentOverdueCollectionData(final Loan loan, fina collectionData.setDelinquentDate(overdueSinceDate); } collectionData.setDelinquentAmount(outstandingAmount); - collectionData.setDelinquentDays(0L); - final long delinquentDays = overdueDays; - if (delinquentDays > 0) { - calculateDelinquentDays(effectiveDelinquencyList, businessDate, collectionData, delinquentDays); - } + calculateAndSetDelinquentDays(collectionData, overdueDays, 0, effectiveDelinquencyList, businessDate, + overdueSinceDateForCalculation); return collectionData; } @@ -356,4 +343,18 @@ private CollectionData calculateDelinquencyDataForNonOverdueInstallment(final Lo return collectionData; } + private void calculateAndSetDelinquentDays(CollectionData collectionData, long overdueDays, Integer graceDays, + List effectiveDelinquencyList, LocalDate businessDate, LocalDate overdueSinceDate) { + collectionData.setDelinquentDays(0L); + if (overdueDays > 0) { + Long pausedDays = delinquencyEffectivePauseHelper.getPausedDaysWithinRange(effectiveDelinquencyList, overdueSinceDate, + businessDate); + if (pausedDays == null) { + pausedDays = 0L; + } + final long delinquentDays = overdueDays - pausedDays - graceDays; + collectionData.setDelinquentDays(delinquentDays > 0 ? delinquentDays : 0L); + } + } + } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java new file mode 100644 index 00000000000..16903d1eb4a --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/delinquency/helper/InstallmentDelinquencyAggregatorTest.java @@ -0,0 +1,278 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.delinquency.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.delinquency.data.LoanInstallmentDelinquencyTagData; +import org.apache.fineract.portfolio.loanaccount.data.InstallmentLevelDelinquency; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for InstallmentDelinquencyAggregator. + * + * These tests cover the critical aggregation logic that groups installment-level delinquency data by range and sums + * amounts. This logic is essential for financial reporting and has zero test coverage before this test class was + * created. + * + * Test scenarios cover: - Same range aggregation (summing amounts) - Different range separation - Multiple installments + * with mixed ranges - Sorting by minimumAgeDays - Empty input handling + */ +class InstallmentDelinquencyAggregatorTest { + + private FineractPlatformTenant testTenant; + private FineractPlatformTenant originalTenant; + + @BeforeEach + void setUp() { + originalTenant = ThreadLocalContextUtil.getTenant(); + testTenant = new FineractPlatformTenant(1L, "test", "Test Tenant", "Asia/Kolkata", null); + ThreadLocalContextUtil.setTenant(testTenant); + MoneyHelper.initializeTenantRoundingMode("test", 4); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.setTenant(originalTenant); + MoneyHelper.clearCache(); + } + + @Test + void testAggregateAndSort_emptyInput_returnsEmptyList() { + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of()); + + assertThat(result).isEmpty(); + } + + @Test + void testAggregateAndSort_singleInstallment_returnsSameInstallment() { + LoanInstallmentDelinquencyTagData data = createTagData(1L, 1L, "RANGE_1", 1, 3, "250.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data)); + + assertThat(result).hasSize(1); + assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "250.00"); + } + + @Test + void testAggregateAndSort_twoInstallmentsSameRange_sumsAmounts() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 3L, "RANGE_3", 4, 60, "250.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 3L, "RANGE_3", 4, 60, "500.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2)); + + assertThat(result).hasSize(1); + assertInstallmentDelinquency(result.get(0), 3L, "RANGE_3", 4, 60, "750.00"); + } + + @Test + void testAggregateAndSort_threeInstallmentsSameRange_sumsAllAmounts() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 2L, "RANGE_2", 2, 3, "100.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 2L, "RANGE_2", 2, 3, "150.00"); + LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 2L, "RANGE_2", 2, 3, "200.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2, data3)); + + assertThat(result).hasSize(1); + assertInstallmentDelinquency(result.get(0), 2L, "RANGE_2", 2, 3, "450.00"); + } + + @Test + void testAggregateAndSort_twoInstallmentsDifferentRanges_remainsSeparate() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "250.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 3L, "RANGE_3", 4, 60, "250.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2)); + + assertThat(result).hasSize(2); + assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "250.00"); + assertInstallmentDelinquency(result.get(1), 3L, "RANGE_3", 4, 60, "250.00"); + } + + @Test + void testAggregateAndSort_multipleInstallmentsMixedRanges_aggregatesAndSeparatesCorrectly() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "100.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "150.00"); + LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 3L, "RANGE_3", 4, 60, "200.00"); + LoanInstallmentDelinquencyTagData data4 = createTagData(4L, 3L, "RANGE_3", 4, 60, "300.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2, data3, data4)); + + assertThat(result).hasSize(2); + assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "250.00"); + assertInstallmentDelinquency(result.get(1), 3L, "RANGE_3", 4, 60, "500.00"); + } + + @Test + void testAggregateAndSort_sortsByMinimumAgeDaysAscending() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 3L, "RANGE_3", 4, 60, "250.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "250.00"); + LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 2L, "RANGE_2", 2, 3, "250.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2, data3)); + + assertThat(result).hasSize(3); + assertEquals(1L, result.get(0).getRangeId()); + assertEquals(Integer.valueOf(1), result.get(0).getMinimumAgeDays()); + assertEquals(2L, result.get(1).getRangeId()); + assertEquals(Integer.valueOf(2), result.get(1).getMinimumAgeDays()); + assertEquals(3L, result.get(2).getRangeId()); + assertEquals(Integer.valueOf(4), result.get(2).getMinimumAgeDays()); + } + + @Test + void testAggregateAndSort_complexScenario_aggregatesSortsCorrectly() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 3L, "RANGE_3", 4, 60, "500.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "250.00"); + LoanInstallmentDelinquencyTagData data3 = createTagData(3L, 3L, "RANGE_3", 4, 60, "250.00"); + LoanInstallmentDelinquencyTagData data4 = createTagData(4L, 2L, "RANGE_2", 2, 3, "100.00"); + LoanInstallmentDelinquencyTagData data5 = createTagData(5L, 1L, "RANGE_1", 1, 3, "150.00"); + + List result = InstallmentDelinquencyAggregator + .aggregateAndSort(List.of(data1, data2, data3, data4, data5)); + + assertThat(result).hasSize(3); + assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "400.00"); + assertInstallmentDelinquency(result.get(1), 2L, "RANGE_2", 2, 3, "100.00"); + assertInstallmentDelinquency(result.get(2), 3L, "RANGE_3", 4, 60, "750.00"); + } + + @Test + void testAggregateAndSort_nullMinimumAgeDays_treatsAsZero() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "NO_DELINQUENCY", null, null, "100.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 2L, "RANGE_1", 1, 3, "200.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2)); + + assertThat(result).hasSize(2); + assertEquals(1L, result.get(0).getRangeId()); + assertEquals(2L, result.get(1).getRangeId()); + } + + @Test + void testAggregateAndSort_decimalPrecision_maintainsPrecision() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "100.12"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 1L, "RANGE_1", 1, 3, "200.34"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getDelinquentAmount()).isEqualByComparingTo("300.46"); + } + + @Test + void testAggregateAndSort_zeroAmounts_includesInResult() { + LoanInstallmentDelinquencyTagData data1 = createTagData(1L, 1L, "RANGE_1", 1, 3, "0.00"); + LoanInstallmentDelinquencyTagData data2 = createTagData(2L, 2L, "RANGE_2", 2, 3, "100.00"); + + List result = InstallmentDelinquencyAggregator.aggregateAndSort(List.of(data1, data2)); + + assertThat(result).hasSize(2); + assertInstallmentDelinquency(result.get(0), 1L, "RANGE_1", 1, 3, "0.00"); + assertInstallmentDelinquency(result.get(1), 2L, "RANGE_2", 2, 3, "100.00"); + } + + private LoanInstallmentDelinquencyTagData createTagData(Long installmentId, Long rangeId, String classification, Integer minDays, + Integer maxDays, String amount) { + return new TestLoanInstallmentDelinquencyTagData(installmentId, + new TestInstallmentDelinquencyRange(rangeId, classification, minDays, maxDays), new BigDecimal(amount)); + } + + private void assertInstallmentDelinquency(InstallmentLevelDelinquency actual, Long expectedRangeId, String expectedClassification, + Integer expectedMinDays, Integer expectedMaxDays, String expectedAmount) { + assertNotNull(actual); + assertEquals(expectedRangeId, actual.getRangeId()); + assertEquals(expectedClassification, actual.getClassification()); + assertEquals(expectedMinDays, actual.getMinimumAgeDays()); + assertEquals(expectedMaxDays, actual.getMaximumAgeDays()); + assertThat(actual.getDelinquentAmount()).isEqualByComparingTo(expectedAmount); + } + + private static class TestLoanInstallmentDelinquencyTagData implements LoanInstallmentDelinquencyTagData { + + private final Long id; + private final InstallmentDelinquencyRange range; + private final BigDecimal amount; + + TestLoanInstallmentDelinquencyTagData(Long id, InstallmentDelinquencyRange range, BigDecimal amount) { + this.id = id; + this.range = range; + this.amount = amount; + } + + @Override + public Long getId() { + return id; + } + + @Override + public InstallmentDelinquencyRange getDelinquencyRange() { + return range; + } + + @Override + public BigDecimal getOutstandingAmount() { + return amount; + } + } + + private static class TestInstallmentDelinquencyRange implements LoanInstallmentDelinquencyTagData.InstallmentDelinquencyRange { + + private final Long id; + private final String classification; + private final Integer minDays; + private final Integer maxDays; + + TestInstallmentDelinquencyRange(Long id, String classification, Integer minDays, Integer maxDays) { + this.id = id; + this.classification = classification; + this.minDays = minDays; + this.maxDays = maxDays; + } + + @Override + public Long getId() { + return id; + } + + @Override + public String getClassification() { + return classification; + } + + @Override + public Integer getMinimumAgeDays() { + return minDays; + } + + @Override + public Integer getMaximumAgeDays() { + return maxDays; + } + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java index 593a37bd001..cd213cbf469 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/deliquency/LoanDelinquencyDomainServiceTest.java @@ -42,7 +42,10 @@ import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.delinquency.domain.LoanDelinquencyAction; import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelper; +import org.apache.fineract.portfolio.delinquency.helper.DelinquencyEffectivePauseHelperImpl; import org.apache.fineract.portfolio.delinquency.service.LoanDelinquencyDomainServiceImpl; import org.apache.fineract.portfolio.delinquency.validator.LoanDelinquencyActionData; import org.apache.fineract.portfolio.loanaccount.data.CollectionData; @@ -159,7 +162,8 @@ public void givenLoanAccountWithOverdueThenCalculateDelinquentData() { when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(repaymentScheduleInstallments.get(0)); when(loan.getCurrency()).thenReturn(currency); when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE); - when(delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate)).thenReturn(0L); + when(delinquencyEffectivePauseHelper.getPausedDaysWithinRange(Mockito.eq(effectiveDelinquencyList), Mockito.any(), + Mockito.eq(businessDate))).thenReturn(0L); CollectionData collectionData = underTest.getOverdueCollectionData(loan, effectiveDelinquencyList); @@ -229,7 +233,8 @@ public void givenLoanInstallmentWithOverdueEnableInstallmentDelinquencyThenCalcu when(loan.getCurrency()).thenReturn(currency); when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true); when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE); - when(delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate)).thenReturn(0L); + when(delinquencyEffectivePauseHelper.getPausedDaysWithinRange(Mockito.eq(effectiveDelinquencyList), Mockito.any(), + Mockito.eq(businessDate))).thenReturn(0L); LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan, effectiveDelinquencyList); @@ -281,7 +286,8 @@ public void givenLoanInstallmentWithoutOverdueWithChargebackAndEnableInstallment when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE); when(loanTransactionReadService.fetchLoanTransactionsByType(loan.getId(), null, LoanTransactionType.CHARGEBACK)) .thenReturn(Arrays.asList(loanTransaction)); - when(delinquencyEffectivePauseHelper.getPausedDaysBeforeDate(effectiveDelinquencyList, businessDate)).thenReturn(0L); + when(delinquencyEffectivePauseHelper.getPausedDaysWithinRange(Mockito.eq(effectiveDelinquencyList), Mockito.any(), + Mockito.eq(businessDate))).thenReturn(0L); LoanDelinquencyData collectionData = underTest.getLoanDelinquencyData(loan, effectiveDelinquencyList); @@ -305,4 +311,108 @@ public void givenLoanInstallmentWithoutOverdueWithChargebackAndEnableInstallment } + @Test + public void givenPausePeriodThenInstallmentDelinquentDaysOnlyIncludeOverlap() { + LocalDate overriddenBusinessDate = LocalDate.of(2022, 3, 2); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, overriddenBusinessDate))); + + LoanRepaymentScheduleInstallment installmentOne = new LoanRepaymentScheduleInstallment(loan, 1, LocalDate.of(2021, 12, 1), + LocalDate.of(2022, 1, 16), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installmentOne.setId(1L); + LoanRepaymentScheduleInstallment installmentTwo = new LoanRepaymentScheduleInstallment(loan, 2, LocalDate.of(2022, 1, 16), + LocalDate.of(2022, 1, 31), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installmentTwo.setId(2L); + LoanRepaymentScheduleInstallment installmentThree = new LoanRepaymentScheduleInstallment(loan, 3, LocalDate.of(2022, 1, 31), + LocalDate.of(2022, 2, 15), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installmentThree.setId(3L); + + List repaymentScheduleInstallments = Arrays.asList(installmentOne, installmentTwo, + installmentThree); + + when(loanProductRelatedDetail.getGraceOnArrearsAgeing()).thenReturn(0); + when(loan.getLoanProductRelatedDetail()).thenReturn(loanProductRelatedDetail); + when(loan.getRepaymentScheduleInstallments()).thenReturn(repaymentScheduleInstallments); + when(loanTransactionReadService.fetchLoanTransactionsByType(loan.getId(), null, LoanTransactionType.CHARGEBACK)) + .thenReturn(Collections.emptyList()); + when(loan.getLastLoanRepaymentScheduleInstallment()).thenReturn(installmentThree); + when(loan.getCurrency()).thenReturn(currency); + when(loan.isEnableInstallmentLevelDelinquency()).thenReturn(true); + when(loan.getStatus()).thenReturn(LoanStatus.ACTIVE); + + LoanDelinquencyAction pauseAction = new LoanDelinquencyAction(null, DelinquencyAction.PAUSE, LocalDate.of(2022, 1, 20), + LocalDate.of(2022, 1, 30)); + pauseAction.setId(1L); + LoanDelinquencyActionData pauseData = new LoanDelinquencyActionData(pauseAction); + List effectiveDelinquencyList = List.of(pauseData); + + LoanDelinquencyDomainServiceImpl service = new LoanDelinquencyDomainServiceImpl(new DelinquencyEffectivePauseHelperImpl(), + loanTransactionReadService); + + LoanDelinquencyData collectionData = service.getLoanDelinquencyData(loan, effectiveDelinquencyList); + + CollectionData loanCollectionData = collectionData.getLoanCollectionData(); + assertEquals(35L, loanCollectionData.getDelinquentDays()); + assertEquals(LocalDate.of(2022, 1, 16), loanCollectionData.getDelinquentDate()); + + Map installments = collectionData.getLoanInstallmentsCollectionData(); + assertNotNull(installments); + assertEquals(3, installments.size()); + assertEquals(35L, installments.get(1L).getDelinquentDays()); + assertEquals(30L, installments.get(2L).getDelinquentDays()); + assertEquals(15L, installments.get(3L).getDelinquentDays()); + } + + @Test + public void givenMultipleInstallmentsAndPauseThenDelinquencyDaysDistributePerInstallment() { + LocalDate businessDate = LocalDate.of(2022, 2, 5); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, businessDate))); + + Loan localLoan = Mockito.mock(Loan.class); + LoanProductRelatedDetail localDetails = Mockito.mock(LoanProductRelatedDetail.class); + MonetaryCurrency currency = new MonetaryCurrency("USD", 2, null); + + LoanRepaymentScheduleInstallment installmentOne = new LoanRepaymentScheduleInstallment(localLoan, 1, LocalDate.of(2021, 12, 26), + LocalDate.of(2022, 1, 10), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installmentOne.setId(1L); + LoanRepaymentScheduleInstallment installmentTwo = new LoanRepaymentScheduleInstallment(localLoan, 2, LocalDate.of(2022, 1, 10), + LocalDate.of(2022, 1, 20), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installmentTwo.setId(2L); + LoanRepaymentScheduleInstallment installmentThree = new LoanRepaymentScheduleInstallment(localLoan, 3, LocalDate.of(2022, 1, 20), + LocalDate.of(2022, 1, 30), principal, zeroAmount, zeroAmount, zeroAmount, false, new HashSet<>(), zeroAmount); + installmentThree.setId(3L); + + List installments = Arrays.asList(installmentOne, installmentTwo, installmentThree); + + when(localLoan.getId()).thenReturn(42L); + when(localLoan.getLoanProductRelatedDetail()).thenReturn(localDetails); + when(localDetails.getGraceOnArrearsAgeing()).thenReturn(0); + when(localLoan.getRepaymentScheduleInstallments()).thenReturn(installments); + when(localLoan.getLastLoanRepaymentScheduleInstallment()).thenReturn(installmentThree); + when(localLoan.getCurrency()).thenReturn(currency); + when(localLoan.isEnableInstallmentLevelDelinquency()).thenReturn(true); + when(localLoan.getStatus()).thenReturn(LoanStatus.ACTIVE); + + when(loanTransactionReadService.fetchLoanTransactionsByType(localLoan.getId(), null, LoanTransactionType.CHARGEBACK)) + .thenReturn(Collections.emptyList()); + + LoanDelinquencyAction pauseAction = new LoanDelinquencyAction(localLoan, DelinquencyAction.PAUSE, LocalDate.of(2022, 1, 15), + LocalDate.of(2022, 1, 25)); + List effectiveDelinquencyList = new DelinquencyEffectivePauseHelperImpl() + .calculateEffectiveDelinquencyList(List.of(pauseAction)); + + LoanDelinquencyDomainServiceImpl service = new LoanDelinquencyDomainServiceImpl(new DelinquencyEffectivePauseHelperImpl(), + loanTransactionReadService); + + LoanDelinquencyData delinquencyData = service.getLoanDelinquencyData(localLoan, effectiveDelinquencyList); + + CollectionData loanCollectionData = delinquencyData.getLoanCollectionData(); + assertEquals(16L, loanCollectionData.getDelinquentDays()); + assertEquals(LocalDate.of(2022, 1, 10), loanCollectionData.getDelinquentDate()); + + Map installmentData = delinquencyData.getLoanInstallmentsCollectionData(); + assertEquals(3, installmentData.size()); + assertEquals(16L, installmentData.get(1L).getDelinquentDays()); + assertEquals(11L, installmentData.get(2L).getDelinquentDays()); + assertEquals(6L, installmentData.get(3L).getDelinquentDays()); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java index 5d45fdd202c..168a029c8b4 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.integrationtests; +import static java.lang.Boolean.FALSE; import static java.lang.Boolean.TRUE; import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.PAUSE; import static org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.RESUME; @@ -27,9 +28,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,6 +44,7 @@ import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdDelinquencyPausePeriod; import org.apache.fineract.client.models.GetLoansLoanIdLoanInstallmentLevelDelinquency; +import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsResponse; @@ -48,6 +54,7 @@ import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper; import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -462,6 +469,379 @@ private Long createLoanProductWith25PctDownPaymentAndDelinquencyBucket(boolean a } + private Long createLoanProductWithDelinquencyBucketNoDownPayment(boolean multiDisburseEnabled, + boolean installmentLevelDelinquencyEnabled, Integer graceOnArrearsAging) { + Integer delinquencyBucketId = DelinquencyBucketsHelper.createDelinquencyBucket(requestSpec, responseSpec, List.of(// + Pair.of(1, 3), // + Pair.of(4, 10), // + Pair.of(11, 60), // + Pair.of(61, null)// + )); + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct(); + product.setDelinquencyBucketId(delinquencyBucketId.longValue()); + product.setMultiDisburseLoan(multiDisburseEnabled); + product.setEnableDownPayment(false); + product.setGraceOnArrearsAgeing(graceOnArrearsAging); + product.setEnableInstallmentLevelDelinquency(installmentLevelDelinquencyEnabled); + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + return loanProductResponse.getResourceId(); + } + + @Test + public void testDelinquentDaysAndDateAfterPastDelinquencyPause() { + final Long[] loanIdHolder = new Long[1]; + + runAt("01 January 2022", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, false, 0); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 2, req -> { + req.setLoanTermFrequency(30); + req.setRepaymentEvery(15); + req.setGraceOnArrearsAgeing(0); + }); + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2022"); + loanIdHolder[0] = loanId; + + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "20 January 2022", "30 January 2022"); + }); + + runAt("02 February 2022", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + + assertNotNull(loanDetails.getDelinquent(), "Delinquent data should not be null"); + + Integer pastDueDays = loanDetails.getDelinquent().getPastDueDays(); + assertNotNull(pastDueDays, "Past due days should not be null"); + assertEquals(17, pastDueDays, "Past due days should be 17 (16 Jan due date to 02 Feb business date)"); + + Integer delinquentDays = loanDetails.getDelinquent().getDelinquentDays(); + assertNotNull(delinquentDays, "Delinquent days should not be null"); + assertEquals(7, delinquentDays, "Delinquent days should be 7 (17 past due days - 10 paused days = 7)"); + + LocalDate delinquentDate = loanDetails.getDelinquent().getDelinquentDate(); + assertNotNull(delinquentDate, "Delinquent date should not be null"); + assertEquals(LocalDate.parse("16 January 2022", dateTimeFormatter), delinquentDate, + "Delinquent date should be 16 Jan 2022 (first installment due date, NOT adjusted for pause)"); + + List pausePeriods = loanDetails.getDelinquent().getDelinquencyPausePeriods(); + assertNotNull(pausePeriods); + assertEquals(1, pausePeriods.size()); + assertEquals(LocalDate.parse("20 January 2022", dateTimeFormatter), pausePeriods.get(0).getPausePeriodStart()); + assertEquals(LocalDate.parse("30 January 2022", dateTimeFormatter), pausePeriods.get(0).getPausePeriodEnd()); + assertEquals(FALSE, pausePeriods.get(0).getActive()); + }); + } + + @Test + public void testInstallmentLevelDelinquencyWithMultipleOverdueInstallments() { + final Long[] loanIdHolder = new Long[1]; + + runAt("01 January 2022", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 3, req -> { + req.setLoanTermFrequency(45); + req.setRepaymentEvery(15); + req.setGraceOnArrearsAgeing(0); + }); + disburseLoan(loanId, BigDecimal.valueOf(100.00), "01 January 2022"); + loanIdHolder[0] = loanId; + + businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE) + .date("05 January 2022").dateFormat(DATETIME_PATTERN).locale("en")); + + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "20 January 2022", "30 January 2022"); + }); + + runAt("02 March 2022", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + + assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null"); + + Integer loanLevelPastDueDays = loanDetails.getDelinquent().getPastDueDays(); + assertEquals(45, loanLevelPastDueDays, "Loan level past due days should be 45 (16 Jan to 02 Mar)"); + + Integer loanLevelDelinquentDays = loanDetails.getDelinquent().getDelinquentDays(); + assertEquals(35, loanLevelDelinquentDays, "Loan level delinquent days should be 35 (45 past due days - 10 paused days = 35)"); + + LocalDate loanLevelDelinquentDate = loanDetails.getDelinquent().getDelinquentDate(); + assertEquals(LocalDate.parse("16 January 2022", dateTimeFormatter), loanLevelDelinquentDate, + "Loan level delinquent date should be 16 Jan 2022 (first installment due date)"); + + Map expectedTotals = calculateExpectedBucketTotals(loanDetails, + LocalDate.parse("02 March 2022", dateTimeFormatter)); + assertTrue(expectedTotals.containsKey("11-60"), "Expected 11-60 bucket to contain delinquent installments"); + assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("02 March 2022", dateTimeFormatter), expectedTotals); + }); + } + + @Test + public void testInstallmentDelinquencyWithSinglePauseAffectingMultipleInstallments() { + final Long[] loanIdHolder = new Long[1]; + + runAt("10 January 2022", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "10 January 2022", 1000.0, 3, req -> { + req.setLoanTermFrequency(30); + req.setRepaymentEvery(10); + req.setGraceOnArrearsAgeing(0); + }); + disburseLoan(loanId, BigDecimal.valueOf(100.00), "10 January 2022"); + loanIdHolder[0] = loanId; + + businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE) + .date("14 January 2022").dateFormat(DATETIME_PATTERN).locale("en")); + + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "15 January 2022", "25 January 2022"); + }); + + runAt("05 February 2022", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null"); + + List delinquencies = loanDetails.getDelinquent() + .getInstallmentLevelDelinquency(); + assertNotNull(delinquencies, "Installment level delinquency should not be null"); + + Map actualTotals = new HashMap<>(); + for (GetLoansLoanIdLoanInstallmentLevelDelinquency delinquency : delinquencies) { + String bucketKey = formatBucketKey(delinquency.getMinimumAgeDays(), delinquency.getMaximumAgeDays()); + actualTotals.merge(bucketKey, delinquency.getDelinquentAmount(), BigDecimal::add); + } + + assertEquals(2, actualTotals.size(), "Should have 2 delinquency buckets"); + assertTrue(actualTotals.containsKey("4-10"), "Should have 4-10 bucket"); + assertTrue(actualTotals.containsKey("11-60"), "Should have 11-60 bucket"); + assertEquals(0, BigDecimal.valueOf(25.0).compareTo(actualTotals.get("4-10")), "4-10 bucket should have 25.0"); + assertEquals(0, BigDecimal.valueOf(25.0).compareTo(actualTotals.get("11-60")), "11-60 bucket should have 25.0"); + }); + } + + @Test + public void testInstallmentDelinquencyWithMultiplePausesAffectingSameInstallment() { + final Long[] loanIdHolder = new Long[1]; + + runAt("01 January 2022", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 1, req -> { + req.setLoanTermFrequency(30); + req.setRepaymentEvery(30); + req.setGraceOnArrearsAgeing(0); + }); + disburseLoan(loanId, BigDecimal.valueOf(100.00), "01 January 2022"); + loanIdHolder[0] = loanId; + }); + + runAt("04 February 2022", () -> { + Long loanId = loanIdHolder[0]; + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "04 February 2022", "09 February 2022"); + }); + + runAt("15 February 2022", () -> { + Long loanId = loanIdHolder[0]; + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "15 February 2022", "20 February 2022"); + }); + + runAt("01 March 2022", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null"); + + LocalDate businessDate = LocalDate.parse("01 March 2022", dateTimeFormatter); + LocalDate installmentDueDate = loanDetails.getDelinquent().getDelinquentDate(); + + Integer loanLevelPastDueDays = loanDetails.getDelinquent().getPastDueDays(); + long expectedPastDueDays = ChronoUnit.DAYS.between(installmentDueDate, businessDate); + assertEquals((int) expectedPastDueDays, loanLevelPastDueDays, + "Loan level past due days should match the business date minus the first installment due date"); + + Integer loanLevelDelinquentDays = loanDetails.getDelinquent().getDelinquentDays(); + long expectedDelinquentDays = Math.max(expectedPastDueDays - 10, 0); + assertEquals((int) expectedDelinquentDays, loanLevelDelinquentDays, + "Loan level delinquent days should subtract both five-day pause periods from the past due days"); + + LocalDate loanLevelDelinquentDate = loanDetails.getDelinquent().getDelinquentDate(); + assertEquals(installmentDueDate, loanLevelDelinquentDate, "Loan level delinquent date should equal the installment due date"); + + List delinquencies = loanDetails.getDelinquent() + .getInstallmentLevelDelinquency(); + assertNotNull(delinquencies, "Installment level delinquency should not be null"); + + Map actualTotals = new HashMap<>(); + for (GetLoansLoanIdLoanInstallmentLevelDelinquency delinquency : delinquencies) { + String bucketKey = formatBucketKey(delinquency.getMinimumAgeDays(), delinquency.getMaximumAgeDays()); + actualTotals.merge(bucketKey, delinquency.getDelinquentAmount(), BigDecimal::add); + } + + assertEquals(1, actualTotals.size(), "Should have 1 delinquency bucket"); + assertTrue(actualTotals.containsKey("11-60"), "Should have 11-60 bucket"); + assertEquals(0, BigDecimal.valueOf(75.0).compareTo(actualTotals.get("11-60")), "11-60 bucket should have 75.0"); + }); + } + + @Test + public void testInstallmentDelinquencyWithPauseBetweenSequentialInstallments() { + final Long[] loanIdHolder = new Long[1]; + + runAt("01 January 2022", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 2, req -> { + req.setLoanTermFrequency(20); + req.setRepaymentEvery(10); + req.setGraceOnArrearsAgeing(0); + }); + disburseLoan(loanId, BigDecimal.valueOf(100.00), "01 January 2022"); + loanIdHolder[0] = loanId; + + businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE) + .date("02 January 2022").dateFormat(DATETIME_PATTERN).locale("en")); + + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "03 January 2022", "10 January 2022"); + }); + + runAt("12 January 2022", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null"); + + Map expectedTotals = calculateExpectedBucketTotals(loanDetails, + LocalDate.parse("12 January 2022", dateTimeFormatter)); + assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("12 January 2022", dateTimeFormatter), expectedTotals); + }); + } + + @Test + public void testInstallmentDelinquencyWithFourInstallmentsAndPausePeriod() { + final Long[] loanIdHolder = new Long[1]; + + runAt("01 January 2022", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWith25PctDownPaymentAndDelinquencyBucket(true, true, true, 0); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1000.0, 4, req -> { + req.setLoanTermFrequency(60); + req.setRepaymentEvery(15); + req.setGraceOnArrearsAgeing(0); + }); + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2022"); + loanIdHolder[0] = loanId; + + businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE) + .date("01 January 2022").dateFormat(DATETIME_PATTERN).locale("en")); + + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "02 January 2022", "20 January 2022"); + }); + + runAt("01 March 2022", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null"); + + Map expectedTotals = calculateExpectedBucketTotals(loanDetails, + LocalDate.parse("01 March 2022", dateTimeFormatter)); + assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("01 March 2022", dateTimeFormatter), expectedTotals); + }); + } + + @Test + public void testPauseUsesBusinessDateNotCOBDate() { + final Long[] loanIdHolder = new Long[1]; + + runAt("28 May 2025", () -> { + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + Long loanProductId = createLoanProductWithDelinquencyBucketNoDownPayment(true, true, 3); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "28 May 2025", 1000.0, 7, req -> { + req.setLoanTermFrequency(210); + req.setRepaymentEvery(30); + req.setGraceOnArrearsAgeing(3); + }); + disburseLoan(loanId, BigDecimal.valueOf(1000.00), "28 May 2025"); + loanIdHolder[0] = loanId; + }); + + runAt("15 June 2025", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "17 June 2025", "19 August 2025"); + }); + + runAt("01 July 2025", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + }); + + runAt("01 August 2025", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + }); + + runAt("01 September 2025", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + }); + + runAt("01 October 2025", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + }); + + runAt("31 October 2025", () -> { + final InlineLoanCOBHelper inlineLoanCOBHelper = new InlineLoanCOBHelper(requestSpec, responseSpec); + Long loanId = loanIdHolder[0]; + inlineLoanCOBHelper.executeInlineCOB(List.of(loanId)); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetails.getDelinquent(), "Loan delinquent data should not be null"); + + Integer loanLevelPastDueDays = loanDetails.getDelinquent().getPastDueDays(); + assertEquals(126, loanLevelPastDueDays, + "Loan level past due days should be 126 (June 27 to Oct 31) - First installment due June 27 (30 days after May 28)"); + + Integer loanLevelDelinquentDays = loanDetails.getDelinquent().getDelinquentDays(); + assertEquals(70, loanLevelDelinquentDays, + "Loan level delinquent days should be 70 (125 overdue days from June 28 to Oct 31, minus 52 paused days from June 28 to Aug 19, minus 3 grace)"); + + LocalDate loanLevelDelinquentDate = loanDetails.getDelinquent().getDelinquentDate(); + assertEquals(LocalDate.parse("30 June 2025", dateTimeFormatter), loanLevelDelinquentDate, + "Loan level delinquent date should be June 30, 2025 (first installment due June 27 + 3 days grace)"); + + Map expectedTotals = calculateExpectedBucketTotals(loanDetails, + LocalDate.parse("31 October 2025", dateTimeFormatter)); + assertInstallmentDelinquencyBuckets(loanDetails, LocalDate.parse("31 October 2025", dateTimeFormatter), expectedTotals); + }); + } + @AllArgsConstructor public static class InstallmentDelinquencyData { @@ -470,4 +850,120 @@ public static class InstallmentDelinquencyData { BigDecimal delinquentAmount; } + private void assertInstallmentDelinquencyBuckets(GetLoansLoanIdResponse loanDetails, LocalDate businessDate, + Map expectedBucketTotals) { + SoftAssertions softly = new SoftAssertions(); + + List delinquencies = loanDetails.getDelinquent().getInstallmentLevelDelinquency(); + softly.assertThat(delinquencies).as("Installment level delinquency should not be null").isNotNull(); + + Map calculatedTotals = calculateExpectedBucketTotals(loanDetails, businessDate); + Map actualTotals = new HashMap<>(); + for (GetLoansLoanIdLoanInstallmentLevelDelinquency delinquency : delinquencies) { + String bucketKey = formatBucketKey(delinquency.getMinimumAgeDays(), delinquency.getMaximumAgeDays()); + actualTotals.merge(bucketKey, delinquency.getDelinquentAmount(), BigDecimal::add); + } + + softly.assertThat(actualTotals.keySet()).as("Unexpected delinquency bucket set").isEqualTo(calculatedTotals.keySet()); + + calculatedTotals.forEach((bucket, expectedAmount) -> { + BigDecimal actualAmount = actualTotals.get(bucket); + softly.assertThat(actualAmount).as("Missing delinquency bucket " + bucket).isNotNull(); + softly.assertThat(actualAmount.setScale(2, RoundingMode.HALF_DOWN)).as("Unexpected delinquent amount for bucket " + bucket) + .isEqualByComparingTo(expectedAmount.setScale(2, RoundingMode.HALF_DOWN)); + }); + + if (expectedBucketTotals != null) { + expectedBucketTotals.forEach((bucket, amount) -> { + BigDecimal calculated = calculatedTotals.get(bucket); + softly.assertThat(calculated).as("Expected bucket " + bucket + " not present in calculated totals").isNotNull(); + softly.assertThat(calculated.setScale(2, RoundingMode.HALF_DOWN)) + .as("Calculated delinquent amount did not match expectation for bucket " + bucket) + .isEqualByComparingTo(amount.setScale(2, RoundingMode.HALF_DOWN)); + }); + } + + BigDecimal loanLevelAmount = loanDetails.getDelinquent().getDelinquentAmount(); + if (loanLevelAmount != null) { + BigDecimal actualSum = actualTotals.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + softly.assertThat(actualSum.setScale(2, RoundingMode.HALF_DOWN)) + .as("Installment bucket totals should sum to the loan level delinquent amount") + .isEqualByComparingTo(loanLevelAmount.setScale(2, RoundingMode.HALF_DOWN)); + } + + softly.assertAll(); + } + + private Map calculateExpectedBucketTotals(GetLoansLoanIdResponse loanDetails, LocalDate businessDate) { + Map totals = new HashMap<>(); + List pauses = loanDetails.getDelinquent().getDelinquencyPausePeriods(); + + for (GetLoansLoanIdRepaymentPeriod period : loanDetails.getRepaymentSchedule().getPeriods()) { + if (Boolean.TRUE.equals(period.getDownPaymentPeriod())) { + continue; + } + LocalDate dueDate = period.getDueDate(); + if (dueDate == null || !dueDate.isBefore(businessDate)) { + continue; + } + BigDecimal outstanding = period.getTotalOutstandingForPeriod(); + if (outstanding == null || outstanding.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + + long pastDueDays = ChronoUnit.DAYS.between(dueDate, businessDate); + if (pastDueDays <= 0) { + continue; + } + + long pausedDays = 0L; + if (pauses != null) { + for (GetLoansLoanIdDelinquencyPausePeriod pause : pauses) { + LocalDate pauseStart = pause.getPausePeriodStart(); + LocalDate pauseEnd = pause.getPausePeriodEnd() != null ? pause.getPausePeriodEnd() : businessDate; + if (pauseStart == null || !pauseEnd.isAfter(pauseStart)) { + continue; + } + LocalDate overlapStart = pauseStart.isAfter(dueDate) ? pauseStart : dueDate; + LocalDate overlapEnd = pauseEnd.isBefore(businessDate) ? pauseEnd : businessDate; + if (overlapEnd.isAfter(overlapStart)) { + pausedDays += ChronoUnit.DAYS.between(overlapStart, overlapEnd); + } + } + } + + long delinquentDays = pastDueDays - pausedDays; + if (delinquentDays <= 0) { + continue; + } + + String bucket = formatBucketKeyForDays(delinquentDays); + totals.merge(bucket, outstanding, BigDecimal::add); + } + return totals; + } + + private String formatBucketKey(Integer minAgeDays, Integer maxAgeDays) { + if (minAgeDays == null) { + return "0"; + } + if (maxAgeDays == null) { + return minAgeDays + "+"; + } + return minAgeDays + "-" + maxAgeDays; + } + + private String formatBucketKeyForDays(long delinquentDays) { + if (delinquentDays >= 1 && delinquentDays <= 3) { + return "1-3"; + } else if (delinquentDays >= 4 && delinquentDays <= 10) { + return "4-10"; + } else if (delinquentDays >= 11 && delinquentDays <= 60) { + return "11-60"; + } else if (delinquentDays >= 61) { + return "61+"; + } + return "0"; + } + } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java index 6f7669bb352..f8853b9925e 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyBucketsIntegrationTest.java @@ -82,6 +82,7 @@ import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; import org.apache.fineract.integrationtests.common.products.DelinquencyBucketsHelper; import org.apache.fineract.integrationtests.common.products.DelinquencyRangesHelper; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -1324,14 +1325,27 @@ public void testDelinquencyWithMultiplePausePeriodsWithInstallmentLevelDelinquen getLoansLoanIdResponse = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); loanTransactionHelper.printDelinquencyData(getLoansLoanIdResponse); GetLoansLoanIdDelinquencySummary delinquent = getLoansLoanIdResponse.getDelinquent(); - assertEquals(2049.99, Utils.getDoubleValue(delinquent.getDelinquentAmount())); - assertEquals(LocalDate.of(2012, 2, 1), delinquent.getDelinquentDate()); - assertEquals(31, delinquent.getDelinquentDays()); - assertEquals(2, delinquent.getInstallmentLevelDelinquency().size()); - GetLoansLoanIdLoanInstallmentLevelDelinquency firstInstallmentDelinquent = delinquent.getInstallmentLevelDelinquency().get(0); - assertEquals(BigDecimal.valueOf(1016.66), firstInstallmentDelinquent.getDelinquentAmount().stripTrailingZeros()); - GetLoansLoanIdLoanInstallmentLevelDelinquency secondInstallmentDelinquent = delinquent.getInstallmentLevelDelinquency().get(1); - assertEquals(BigDecimal.valueOf(1033.33), secondInstallmentDelinquent.getDelinquentAmount().stripTrailingZeros()); + + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(Utils.getDoubleValue(delinquent.getDelinquentAmount())).as("Total delinquent amount").isEqualTo(2049.99); + softly.assertThat(delinquent.getDelinquentDate()).as("Delinquent date").isEqualTo(LocalDate.of(2012, 2, 1)); + softly.assertThat(delinquent.getDelinquentDays()).as("Delinquent days").isEqualTo(31); + + // Installment-level delinquency is aggregated by range + // Both installments (31 days and 13 days) fall into Range 2 (4-60 days) + // So we expect 1 aggregated entry with total amount 2049.99 + softly.assertThat(delinquent.getInstallmentLevelDelinquency()).as("Installment level delinquency size").hasSize(1); + + if (delinquent.getInstallmentLevelDelinquency().size() >= 1) { + GetLoansLoanIdLoanInstallmentLevelDelinquency rangeDelinquency = delinquent.getInstallmentLevelDelinquency().get(0); + // This is the aggregated amount for all installments in Range 2 (4-60 days) + softly.assertThat(rangeDelinquency.getDelinquentAmount().stripTrailingZeros()) + .as("Range 2 (4-60 days) aggregated delinquent amount").isEqualByComparingTo(BigDecimal.valueOf(2049.99)); + softly.assertThat(rangeDelinquency.getMinimumAgeDays()).as("Range minimum days").isEqualTo(4); + softly.assertThat(rangeDelinquency.getMaximumAgeDays()).as("Range maximum days").isEqualTo(60); + } + + softly.assertAll(); }); } From 4c17c1d56309a0d1e2717f09cb37ba3a1f6c3e04 Mon Sep 17 00:00:00 2001 From: Peter Kovacs Date: Tue, 21 Oct 2025 13:45:39 +0200 Subject: [PATCH 23/36] FINERACT-2326: fix delinquency date calculation after pause - E2E test --- .../features/LoanDelinquency.feature | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature index d12375f3714..a382d7e919b 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature @@ -2105,3 +2105,42 @@ Feature: LoanDelinquency When Loan Pay-off is made on "1 September 2024" Then Loan's all installments have obligations met + + @TestRailId:C4130 + Scenario: Verify that paused days are not counted in installment level delinquency + When Admin sets the business date to "28 May 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY | 28 May 2025 | 1000 | 0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "28 May 2025" with "1000" amount and expected disbursement date on "28 May 2025" + And Admin successfully disburse the loan on "28 May 2025" with "1000" EUR transaction amount +# --- Delinquency pause --- + And Admin sets the business date to "15 June 2025" + And Admin runs inline COB job for Loan + And Admin initiate a DELINQUENCY PAUSE with startDate: "17 June 2025" and endDate: "19 August 2025" + And Admin sets the business date to "01 July 2025" + And Admin runs inline COB job for Loan + And Admin sets the business date to "01 August 2025" + And Admin runs inline COB job for Loan + And Admin sets the business date to "01 September 2025" + And Admin runs inline COB job for Loan + And Admin sets the business date to "01 October 2025" + And Admin runs inline COB job for Loan + And Admin sets the business date to "31 October 2025" + And Admin runs inline COB job for Loan + Then Delinquency-actions have the following data: + | action | startDate | endDate | + | PAUSE | 17 June 2025 | 19 August 2025 | + And Loan Delinquency pause periods has the following data: + | active | pausePeriodStart | pausePeriodEnd | + | false | 17 June 2025 | 19 August 2025 | + And Loan has the following LOAN level delinquency data: + | classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays | + | RANGE_60 | 875.0 | 31 May 2025 | 90 | 156 | + And Loan has the following INSTALLMENT level delinquency data: + | rangeId | Range | Amount | + | 1 | RANGE_1 | 125.00 | + | 3 | RANGE_30 | 125.00 | + | 4 | RANGE_60 | 375.00 | + | 5 | RANGE_90 | 250.00 | From c2b669dd51a1791c19251969ba11fad2720df76a Mon Sep 17 00:00:00 2001 From: Adam Monsen Date: Wed, 22 Oct 2025 07:44:22 -0700 Subject: [PATCH 24/36] FINERACT-2397: update release manager docs post-1.13.0 release (#5116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release step 6: emphasize "re-run the command above" * release step 9: link to Database Setup in e2e or integration section (currently links to e2e since `cucumber.adoc` precedes `integration.adoc` in `fineract-doc/src/docs/en/chapters/testing/index.adoc`) * release step 10: improve rc verify instructions in voting email, based on https://lists.apache.org/thread/jk2qzjxc89hd1c8xzvhtmlbbj1m8ktbl - thread: "release process improvement (was: 🗳️ 1.13.0 for release)" * release step 15: use current mission statement from top-level readme in release announcement email --- .../resources/email/release.step10.vote.message.ftl | 10 ++++------ .../email/release.step15.announce.message.ftl | 5 +---- .../src/docs/en/chapters/release/process-step06.adoc | 2 +- .../src/docs/en/chapters/release/process-step09.adoc | 2 +- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/buildSrc/src/main/resources/email/release.step10.vote.message.ftl b/buildSrc/src/main/resources/email/release.step10.vote.message.ftl index 96d3fd38d11..cf91d174611 100644 --- a/buildSrc/src/main/resources/email/release.step10.vote.message.ftl +++ b/buildSrc/src/main/resources/email/release.step10.vote.message.ftl @@ -38,15 +38,13 @@ This vote will be open for 72 hours: [ ] +0 no opinion [ ] -1 disapprove (and reason why) -Please indicate if you are a binding vote (member of the PMC). Note: PMC members are required to download, compile, and test the artifacts before submitting their +1 vote. +Please indicate if yours is a binding vote, and "Verified: YES/NO/PARTIAL". -Please also indicate with "Tested: YES/NO/PARTIAL" if you have locally built and/or tested these artifacts and/or a clone of the code checked out to the release commit, following the form: +Verified: YES ... Verified integrity and signatures of release artifacts locally, built from source, ran jar/war: Did everything mentioned in the current release candidate verification guidance ( https://fineract.apache.org/docs/rc/#_artifact_verification ). If you did more than that, please specify. "Verified: YES" is required for binding votes. -Tested: YES ... Verified integrity and signatures of release artifacts locally, built from source, ran jar/war: Did everything mentioned in the current release candidate verification guidance ( https://fineract.apache.org/docs/rc/#_artifact_verification ). If you did more than that, please specify. +Verified: NO ... No testing performed on release candidate, e.g. relying on testing performed by other contributors and/or output of GitHub Actions, while exercising your right to vote. -Tested: NO ... No testing performed on release candidate, e.g. relying on testing performed by other contributors and/or output of GitHub Actions, while exercising my right to vote. - -Tested: PARTIAL ... Please specify. +Verified: PARTIAL ... Please specify. Cheers, diff --git a/buildSrc/src/main/resources/email/release.step15.announce.message.ftl b/buildSrc/src/main/resources/email/release.step15.announce.message.ftl index e08c44dc285..4ccb8322eb7 100644 --- a/buildSrc/src/main/resources/email/release.step15.announce.message.ftl +++ b/buildSrc/src/main/resources/email/release.step15.announce.message.ftl @@ -23,10 +23,7 @@ the release of Apache Fineract ${project['fineract.release.version']}. The release is available for download from https://fineract.apache.org/#downloads -Fineract provides a reliable, robust, and affordable solution for entrepreneurs, -financial institutions, and service providers to offer financial services to the -world’s 2 billion underbanked and unbanked. Fineract is aimed at innovative mobile -and cloud-based solutions, and enables digital transaction accounts for all. +Apache Fineract is an open-source core banking platform providing a flexible, extensible foundation for a wide range of financial services. By making robust banking technology openly available, it lowers barriers for institutions and innovators to reach underserved and unbanked populations. This release addressed ${project['fineract.release.issues']?size} issues. diff --git a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc index ed970fd5be0..014d1757243 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step06.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step06.adoc @@ -9,7 +9,7 @@ Create source and binary tarballs. ./gradlew clean srcDistTar binaryDistTar ---- -Check that `fineract-provider/build/classes/java/main/git.properties` exists. If so, continue. If not, you're likely encountering https://github.com/n0mer/gradle-git-properties/issues/233[this bug], and you need to re-run the command above to create proper source and binary tarballs. That `git.properties` file is supposed to end up at `BOOT-INF/classes/git.properties` in `fineract-provider-{revnumber}.jar` in the binary release tarball. Its contents are displayed at the `/fineract-provider/actuator/info` endpoint. It may be possible to fix this heisenbug entirely by https://github.com/gradle/gradle/issues/34177#issuecomment-3051970053[modifying our git properties gradle plugin config] in `fineract-provider/build.gradle`, perhaps by changing where `git.properties` is written. +Check that `fineract-provider/build/classes/java/main/git.properties` exists. If so, continue. If not, you're likely encountering https://github.com/n0mer/gradle-git-properties/issues/233[this bug], and you need to *re-run the command above* to create proper source and binary tarballs. That `git.properties` file is supposed to end up at `BOOT-INF/classes/git.properties` in `fineract-provider-{revnumber}.jar` in the binary release tarball. Its contents are displayed at the `/fineract-provider/actuator/info` endpoint. It may be possible to fix this heisenbug entirely by https://github.com/gradle/gradle/issues/34177#issuecomment-3051970053[modifying our git properties gradle plugin config] in `fineract-provider/build.gradle`, perhaps by changing where `git.properties` is written. Look in `fineract-war/build/distributions/` for the tarballs. diff --git a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc index 77fe3e95671..c0d177646f7 100644 --- a/fineract-doc/src/docs/en/chapters/release/process-step09.adoc +++ b/fineract-doc/src/docs/en/chapters/release/process-step09.adoc @@ -74,7 +74,7 @@ cd .. === Run from binary -Before running Fineract you must first start a <> and ensure the `fineract_default` and `fineract_tenants` databases exist. Detailed steps for database preparation are left as an exercise for the reader. You can find ideas on how to prepare your database in the `build-mariadb.yml`, `build-mysql.yml`, and `build-postgresql.yml` files in source control. +Before running Fineract you must first start a <> and ensure the `fineract_default` and `fineract_tenants` databases exist. Detailed steps for database preparation are left as an exercise for the reader. You can find ideas on how to prepare your database in the `build-mariadb.yml`, `build-mysql.yml`, and `build-postgresql.yml` files in source control, and in <>. Finally, start your Fineract server: From c181490dda5b4b08345a918db6ba4f8fbad7b73f Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Wed, 22 Oct 2025 20:15:10 +0200 Subject: [PATCH 25/36] FINERACT-2389: Automatically close loans at the end of test executions --- .../client/feign/FeignLoanTestBase.java | 3 +++ .../common/loans/LoanTestLifecycleExtension.java | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) 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 index 0a4155ca8a6..a0806cae80a 100644 --- 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 @@ -40,8 +40,11 @@ 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.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +@ExtendWith(LoanTestLifecycleExtension.class) public abstract class FeignLoanTestBase extends FeignIntegrationTest implements LoanProductTemplates { protected static FeignAccountHelper accountHelper; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java index d10508569b1..76e9c49ba7b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTestLifecycleExtension.java @@ -36,9 +36,10 @@ import org.apache.fineract.integrationtests.common.FineractClientHelper; import org.apache.fineract.integrationtests.common.Utils; import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; -public class LoanTestLifecycleExtension implements AfterEachCallback { +public class LoanTestLifecycleExtension implements AfterEachCallback, BeforeEachCallback { private LoanTransactionHelper loanTransactionHelper; public static final String DATE_FORMAT = "dd MMMM yyyy"; @@ -46,6 +47,15 @@ public class LoanTestLifecycleExtension implements AfterEachCallback { @Override public void afterEach(ExtensionContext context) { + closeOpenLoans(); + } + + @Override + public void beforeEach(ExtensionContext context) { + closeOpenLoans(); + } + + private void closeOpenLoans() { BusinessDateHelper.runAt(DateTimeFormatter.ofPattern(DATE_FORMAT).format(Utils.getLocalDateOfTenant()), () -> { this.loanTransactionHelper = new LoanTransactionHelper(null, null); From 7c9c00e13584b2e1b10b304264a525502fdab77f Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Thu, 23 Oct 2025 00:27:18 +0200 Subject: [PATCH 26/36] FINERACT-2389: Improve E2E test data initialization resilience --- .../factory/LoanProductsRequestFactory.java | 77 +++++++++++-------- .../LoanProductGlobalInitializerStep.java | 12 ++- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java index ab9d08312ba..e9760394741 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java @@ -22,7 +22,9 @@ import java.math.BigDecimal; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.fineract.client.models.AllowAttributeOverrides; import org.apache.fineract.client.models.GetLoanPaymentChannelToFundSourceMappings; @@ -67,6 +69,8 @@ public class LoanProductsRequestFactory { private final AccountTypeResolver accountTypeResolver; private final CodeValueResolver codeValueResolver; + private final Set productShortNameMap = new HashSet<>(); + @Autowired private CodeHelper codeHelper; @@ -78,9 +82,6 @@ public class LoanProductsRequestFactory { public static final String NAME_PREFIX_INTEREST_DECLINING = "LP1InterestDeclining-"; public static final String NAME_PREFIX_INTEREST_DECLINING_RECALCULATION = "LP1InterestDecliningRecalculation-"; public static final String NAME_PREFIX_INTEREST_RECALCULATION = "LP2InterestRecalculation-"; - public static final String SHORT_NAME_PREFIX = "p"; - public static final String SHORT_NAME_PREFIX_INTEREST = "i"; - public static final String SHORT_NAME_PREFIX_EMI = "e"; public static final String DATE_FORMAT = "dd MMMM yyyy"; public static final String LOCALE_EN = "en"; public static final String DESCRIPTION = "30 days repayment"; @@ -126,8 +127,8 @@ public class LoanProductsRequestFactory { public static final String CHARGE_OFF_REASONS = "ChargeOffReasons"; public PostLoanProductsRequest defaultLoanProductsRequestLP1() { - String name = Utils.randomNameGenerator(NAME_PREFIX, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -236,8 +237,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP1() { } public PostLoanProductsRequest defaultLoanProductsRequestLP1InterestFlat() { - String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_FLAT, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_FLAT, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -344,8 +345,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP1InterestFlat() { } public PostLoanProductsRequest defaultLoanProductsRequestLP1InterestDeclining() { - String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_DECLINING, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_DECLINING, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -452,8 +453,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP1InterestDeclining() } public PostLoanProductsRequest defaultLoanProductsRequestLP11MonthInterestDecliningBalanceDailyRecalculationCompoundingMonthly() { - String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_DECLINING_RECALCULATION, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_DECLINING_RECALCULATION, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -565,8 +566,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP11MonthInterestDeclin } public PostLoanProductsRequest defaultLoanProductsRequestLP1InterestDecliningBalanceDailyRecalculationCompoundingNone() { - String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_DECLINING_RECALCULATION, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_DECLINING_RECALCULATION, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -675,8 +676,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP1InterestDecliningBal } public PostLoanProductsRequest defaultLoanProductsRequestLP2InterestDailyRecalculation() { - final String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_RECALCULATION, 4); - final String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); + final String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_RECALCULATION, 10); + final String shortName = generateShortNameSafely(); List penaltyToIncomeAccountMappings = new ArrayList<>(); List feeToIncomeAccountMappings = new ArrayList<>(); @@ -790,8 +791,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2InterestDailyRecalcu } public PostLoanProductsRequest defaultLoanProductsRequestLP2() { - String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -903,8 +904,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2() { } public PostLoanProductsRequest defaultLoanProductsRequestLP2InterestFlat() { - String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_FLAT_LP2, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_FLAT_LP2, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -1014,8 +1015,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2InterestFlat() { } public PostLoanProductsRequest defaultLoanProductsRequestLP2Emi() { - String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -1124,8 +1125,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2Emi() { } public PostLoanProductsRequest defaultLoanProductsRequestLP2EmiWithChargeOff() { - String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -1254,8 +1255,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2EmiWithChargeOff() { } public PostLoanProductsRequest defaultLoanProductsRequestLP2ChargeOffReasonToExpenseAccountMappings() { - final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 4); - final String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3); + final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 10); + final String shortName = generateShortNameSafely(); final List principalVariationsForBorrowerCycle = new ArrayList<>(); final List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -1376,8 +1377,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2ChargeOffReasonToExp } public PostLoanProductsRequest defaultLoanProductsRequestLP2EmiCashAccounting() { - String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 4); - String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3); + String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 10); + String shortName = generateShortNameSafely(); List principalVariationsForBorrowerCycle = new ArrayList<>(); List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); @@ -1503,8 +1504,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2EmiCashAccounting() } public PostLoanProductsRequest defaultLoanProductsRequestLP2CapitalizedIncome() { - final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 4); - final String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3); + final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 10); + final String shortName = generateShortNameSafely(); final List principalVariationsForBorrowerCycle = new ArrayList<>(); final List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); final List interestRateVariationsForBorrowerCycle = new ArrayList<>(); @@ -1641,8 +1642,8 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2ChargeOffReasonToExp } public PostLoanProductsRequest defaultLoanProductsRequestLP2BuyDownFees() { - final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 4); - final String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3); + final String name = Utils.randomNameGenerator(NAME_PREFIX_LP2, 10); + final String shortName = generateShortNameSafely(); final List principalVariationsForBorrowerCycle = new ArrayList<>(); final List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); final List interestRateVariationsForBorrowerCycle = new ArrayList<>(); @@ -1777,4 +1778,18 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2ChargeOffReasonToExp return defaultLoanProductsRequestLP2BuyDownFees()// .chargeOffReasonToExpenseAccountMappings(chargeOffReasonToExpenseAccountMappings);// } + + public String generateShortNameSafely() { + String generatedShortName; + int counter = 0; + do { + counter++; + generatedShortName = Utils.randomNameGenerator("", 4); + if (counter > 999) { + throw new RuntimeException("Unable to generate unique short name"); + } + } while (productShortNameMap.contains(generatedShortName)); + productShortNameMap.add(generatedShortName); + return generatedShortName; + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index 337acc99b72..174749caf1c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -26,7 +26,6 @@ import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_RATE_FREQUENCY_TYPE_YEAR; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.LOAN_ACCOUNTING_RULE_NONE; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.REPAYMENT_FREQUENCY_TYPE_MONTHS; -import static org.apache.fineract.test.factory.LoanProductsRequestFactory.SHORT_NAME_PREFIX_EMI; import java.math.BigDecimal; import java.util.ArrayList; @@ -62,7 +61,6 @@ import org.apache.fineract.test.data.loanproduct.DefaultLoanProduct; import org.apache.fineract.test.factory.LoanProductsRequestFactory; import org.apache.fineract.test.helper.CodeHelper; -import org.apache.fineract.test.helper.Utils; import org.apache.fineract.test.support.TestContext; import org.apache.fineract.test.support.TestContextKey; import org.springframework.stereotype.Component; @@ -2290,7 +2288,7 @@ public void initialize() throws Exception { final String name91 = DefaultLoanProduct.LP2_ADV_PYMNT_INT_DAILY_EMI_ACTUAL_ACTUAL_INT_REFUND_FULL_ZERO_INT_CHARGE_OFF.getName(); final PostLoanProductsRequest loanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOff = loanProductsRequestLP2AdvancedpaymentInterestEmiActualActualInterestRefundFull .name(name91)// - .shortName(Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3))// + .shortName(loanProductsRequestFactory.generateShortNameSafely())// .chargeOffBehaviour("ZERO_INTEREST");// final Response responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOff = loanProductsApi .createLoanProduct(loanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullZeroIntChargeOff).execute(); @@ -2305,7 +2303,7 @@ public void initialize() throws Exception { .getName(); final PostLoanProductsRequest loanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullAccelerateMaturityChargeOff = loanProductsRequestLP2AdvancedpaymentInterestEmiActualActualInterestRefundFull .name(name92)// - .shortName(Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3))// + .shortName(loanProductsRequestFactory.generateShortNameSafely())// .chargeOffBehaviour("ACCELERATE_MATURITY");// final Response responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullAccelerateMaturityChargeOff = loanProductsApi .createLoanProduct(loanProductsRequestLP2AdvPaymentIntEmiActualActualIntRefundFullAccelerateMaturityChargeOff).execute(); @@ -2340,7 +2338,7 @@ public void initialize() throws Exception { .getName(); final PostLoanProductsRequest loanProductsRequestLP2AdvPaymentIntEmiActualActualNoInterestRecalcIntRefundFullZeroIntChargeOff = loanProductsRequestLP2AdvancedPaymentInterestEmiActualActualNoInterestRecalcRefundFull .name(name94)// - .shortName(Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3))// + .shortName(loanProductsRequestFactory.generateShortNameSafely())// .chargeOffBehaviour("ZERO_INTEREST");// final Response responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualNoInterestRecalcIntRefundFullZeroIntChargeOff = loanProductsApi .createLoanProduct(loanProductsRequestLP2AdvPaymentIntEmiActualActualNoInterestRecalcIntRefundFullZeroIntChargeOff) @@ -2356,7 +2354,7 @@ public void initialize() throws Exception { .getName(); final PostLoanProductsRequest loanProductsRequestLP2AdvPaymentIntEmiActualActualNoInterestRecalcIntRefundFullAccelerateMaturityChargeOff = loanProductsRequestLP2AdvancedPaymentInterestEmiActualActualNoInterestRecalcRefundFull .name(name95)// - .shortName(Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3))// + .shortName(loanProductsRequestFactory.generateShortNameSafely())// .chargeOffBehaviour("ACCELERATE_MATURITY");// final Response responseLoanProductsRequestLP2AdvPaymentIntEmiActualActualNoInterestRecalcIntRefundFullAccelerateMaturityChargeOff = loanProductsApi .createLoanProduct( @@ -3155,8 +3153,8 @@ public void initialize() throws Exception { .add(new LoanProductChargeData().id(ChargeProductType.LOAN_INSTALLMENT_FEE_PERCENTAGE_INTEREST.value)); final PostLoanProductsRequest loanProductsRequestLP2AdvPaymentInstallmentFeeFlatPlusInterestChargesMultiDisburse = loanProductsRequestLP2AdvPaymentInstallmentFeePercentInterestCharges// .name(name121)// + .shortName(loanProductsRequestFactory.generateShortNameSafely())// .charges(chargesInstallmentFeeFlatPlusInterest)// - .shortName(Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3))// .disallowExpectedDisbursements(true)// .maxTrancheCount(10)// .outstandingLoanBalance(10000.0);// From 57e1701a970712d5bca8642624a419377f23a163 Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Fri, 26 Sep 2025 08:16:44 -0500 Subject: [PATCH 27/36] FINERACT-2382: Repayment schedule for Flat-Cumulative-Multi Disbursement --- .../core/data/DataValidatorBuilder.java | 2 +- .../domain/LoanApplicationTerms.java | 4 +- .../LoanApplicationValidator.java | 29 +------- .../LoanProductDataValidator.java | 8 +-- ...ntAllocationLoanRepaymentScheduleTest.java | 69 +++++++++++++++++++ 5 files changed, 75 insertions(+), 37 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java index c8745d7a365..26476f7c484 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java @@ -494,7 +494,7 @@ public DataValidatorBuilder integerSameAsNumber(Integer number) { final Integer intValue = Integer.valueOf(this.value.toString()); if (!intValue.equals(number)) { String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + ".not.equal.to.specified.number"; - String defaultEnglishMessage = "The parameter `" + this.parameter + "` must be same as" + number; + String defaultEnglishMessage = "The parameter `" + this.parameter + "` must be same as " + number; final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultEnglishMessage, this.parameter, intValue, number); this.dataValidationErrors.add(error); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java index d9c7b10587e..6ef3cbe7da0 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java @@ -1085,7 +1085,7 @@ private BigDecimal calculateFlatInterestRateForLoanTerm(final PaymentPeriodsInOn final BigDecimal loanTermFrequencyBigDecimal = calculatePeriodsInLoanTerm(); return this.annualNominalInterestRate.divide(loanTermPeriodsInYearBigDecimal, mc).divide(divisor, mc) - .multiply(loanTermFrequencyBigDecimal); + .multiply(loanTermFrequencyBigDecimal, mc); } private BigDecimal calculatePeriodsInLoanTerm() { @@ -1237,7 +1237,7 @@ private Money calculateTotalPrincipalPerPeriodWithoutGrace(final MathContext mc, } if (this.installmentAmountInMultiplesOf != null) { - Money roundedPrincipalPerPeriod = Money.roundToMultiplesOf(principalPerPeriod, this.installmentAmountInMultiplesOf); + Money roundedPrincipalPerPeriod = Money.roundToMultiplesOf(principalPerPeriod, this.installmentAmountInMultiplesOf, mc); if (interestForThisInstallment != null) { Money roundedInterestForThisInstallment = Money.roundToMultiplesOf(interestForThisInstallment, this.installmentAmountInMultiplesOf); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java index 6ab9f9516f7..10d4c3f8dcf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java @@ -214,8 +214,6 @@ public void validateForCreate(final Loan loan) { expectedFirstRepaymentOnDate); } - validateCumulativeMultiDisburse(loan); - validateLoanTermAndRepaidEveryValues(loan.getTermFrequency(), loan.getTermPeriodFrequencyType().getValue(), loan.getLoanProductRelatedDetail().getNumberOfRepayments(), loan.getLoanProductRelatedDetail().getRepayEvery(), loan.getLoanProductRelatedDetail().getRepaymentPeriodFrequencyType().getValue(), loan); @@ -230,8 +228,6 @@ public void validateForModify(final Loan loan) { expectedFirstRepaymentOnDate); } - validateCumulativeMultiDisburse(loan); - validateLoanTermAndRepaidEveryValues(loan.getTermFrequency(), loan.getTermPeriodFrequencyType().getValue(), loan.getLoanProductRelatedDetail().getNumberOfRepayments(), loan.getLoanProductRelatedDetail().getRepayEvery(), loan.getLoanProductRelatedDetail().getRepaymentPeriodFrequencyType().getValue(), loan); @@ -1742,16 +1738,8 @@ public void validateLoanMultiDisbursementDate(final JsonElement element, final D if (transactionProcessingStrategyCode != null) { final Integer interestType = this.fromApiJsonHelper.extractIntegerNamed(LoanApiConstants.interestTypeParameterName, element, Locale.getDefault()); - String processorCode = loanRepaymentScheduleTransactionProcessorFactory - .determineProcessor(transactionProcessingStrategyCode).getCode(); - boolean isProgressive = "advanced-payment-allocation-strategy".equals(processorCode); - if (isProgressive) { - baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName).value(interestType) - .ignoreIfNull().inMinMaxRange(0, 1); - } else { - baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName).value(interestType) - .ignoreIfNull().integerSameAsNumber(InterestMethod.DECLINING_BALANCE.getValue()); - } + baseDataValidator.reset().parameter(LoanApiConstants.interestTypeParameterName).value(interestType).ignoreIfNull() + .inMinMaxRange(0, 1); } } else { if (loan.isCumulativeSchedule()) { @@ -2208,19 +2196,6 @@ public void validateDisbursementDateWithMeetingDates(final LocalDate expectedDis } } - private static void validateCumulativeMultiDisburse(Loan loan) { - if (loan.isCumulativeSchedule() && loan.isMultiDisburmentLoan() - && loan.getLoanProductRelatedDetail().getInterestMethod().isFlat()) { - final List dataValidationErrors = new ArrayList<>(); - final ApiParameterError error = ApiParameterError.generalError( - "validation.msg.loan.cumulative.multidisburse.does.not.support.flat.interest.mode", - "Cumulative multidisburse loan does NOT support FLAT interest mode."); - dataValidationErrors.add(error); - throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", - dataValidationErrors); - } - } - private Calendar getCalendarInstance(Loan loan) { CalendarInstance calendarInstance = calendarInstanceRepository.findCalendarInstanceByEntityId(loan.getId(), CalendarEntityType.LOANS.getValue()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java index 9132f39f632..2bc26a6b7c2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java @@ -1059,13 +1059,7 @@ private void validateMultiDisburseLoanData(final DataValidatorBuilder baseDataVa final Integer interestType = this.fromApiJsonHelper.extractIntegerNamed(INTEREST_TYPE, element, Locale.getDefault()); - boolean isProgressive = isProgressive(element, loanProduct); - if (isProgressive) { - baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull().inMinMaxRange(0, 1); - } else { - baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull() - .integerSameAsNumber(InterestMethod.DECLINING_BALANCE.getValue()); - } + baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull().inMinMaxRange(0, 1); } final String overAppliedCalculationType = this.fromApiJsonHelper.extractStringNamed(OVER_APPLIED_CALCULATION_TYPE, element); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java index 540a37267ab..87ad48e08de 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/AdvancedPaymentAllocationLoanRepaymentScheduleTest.java @@ -6251,6 +6251,75 @@ public void uc157() { }); } + // UC158: Repayment schedule handling for flat cumulative multi-disbursement + @Test + public void uc158() { + AtomicLong loanIdRef = new AtomicLong(); + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + final BigDecimal principalAmount = BigDecimal.valueOf(2000.0); + + runAt("1 January 2024", () -> { + // Create a Cumulative Multidisbursal and Flat Interest Type + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct( + createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct().interestType(InterestType.FLAT).daysInMonthType(30)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.DEFAULT_STRATEGY).interestRateFrequencyType(YEARS) + .daysInYearType(365).loanScheduleType(LoanScheduleType.CUMULATIVE.toString()).repaymentEvery(1) + .installmentAmountInMultiplesOf(null)// + .repaymentFrequencyType(2L)// + ); + assertNotNull(loanProductResponse.getResourceId()); + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductResponse.getResourceId(), "1 January 2024", + principalAmount.doubleValue(), 3).interestCalculationPeriodType(1).interestType(InterestType.FLAT)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.DEFAULT_STRATEGY).interestRateFrequencyType(YEARS)// + .interestRatePerPeriod(BigDecimal.valueOf(7.0))// + .repaymentEvery(1)// + .repaymentFrequencyType(MONTHS)// + .loanTermFrequency(3)// + .loanTermFrequencyType(MONTHS); + PostLoansResponse loanResponse = loanTransactionHelper.applyLoan(applicationRequest); + + loanTransactionHelper.approveLoan(loanResponse.getLoanId(), new PostLoansLoanIdRequest().approvedLoanAmount(principalAmount) + .dateFormat(DATETIME_PATTERN).approvedOnDate("1 January 2024").locale("en")); + + loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), + new PostLoansLoanIdRequest().actualDisbursementDate("1 January 2024").dateFormat(DATETIME_PATTERN).locale("en") + .transactionAmount(BigDecimal.valueOf(1000.00))); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanResponse.getLoanId()); + validateLoanSummaryBalances(loanDetails, 1017.50, 0.00, 1000.00, 0.00, null); + validatePeriod(loanDetails, 0, LocalDate.of(2024, 1, 1), null, 1000.0, null, null, null, 0.0, 0.0, null, null, null, null, null, + null, null, null, null); + validatePeriod(loanDetails, 1, LocalDate.of(2024, 2, 1), null, 666.67, 333.33, 0.00, 333.33, 0.0, 0.0, 0.0, 0.00, 0.00, 0.00, + 5.83, 0.00, 5.83, 0.00, 0.00); + validatePeriod(loanDetails, 2, LocalDate.of(2024, 3, 1), null, 333.34, 333.33, 0.00, 333.33, 0.0, 0.0, 0.0, 0.00, 0.00, 0.00, + 5.83, 0.00, 5.83, 0.00, 0.00); + validatePeriod(loanDetails, 3, LocalDate.of(2024, 4, 1), null, 0.00, 333.34, 0.00, 333.34, 0.0, 0.0, 0.00, 0.00, 0.00, 0.00, + 5.84, 0.00, 5.84, 0.00, 0.00); + loanIdRef.set(loanResponse.getLoanId()); + }); + + runAt("15 January 2024", () -> { + final Long loanId = loanIdRef.get(); + + loanTransactionHelper.disburseLoan(loanId, new PostLoansLoanIdRequest().actualDisbursementDate("15 January 2024") + .dateFormat(DATETIME_PATTERN).locale("en").transactionAmount(BigDecimal.valueOf(500.0))); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + validateLoanSummaryBalances(loanDetails, 1526.25, 0.00, 1500.00, 0.00, null); + validatePeriod(loanDetails, 0, LocalDate.of(2024, 1, 1), null, 1000.0, null, null, null, 0.0, 0.0, null, null, null, null, null, + null, null, null, null); + validatePeriod(loanDetails, 1, LocalDate.of(2024, 1, 15), null, 500.0, null, null, null, 0.0, 0.0, null, null, null, null, null, + null, null, null, null); + validatePeriod(loanDetails, 2, LocalDate.of(2024, 2, 1), null, 1000.00, 500.00, 0.00, 500.00, 0.0, 0.0, 0.0, 0.00, 0.00, 0.00, + 8.75, 0.00, 8.75, 0.00, 0.00); + validatePeriod(loanDetails, 3, LocalDate.of(2024, 3, 1), null, 500.00, 500.00, 0.00, 500.00, 0.0, 0.0, 0.0, 0.00, 0.00, 0.00, + 8.75, 0.00, 8.75, 0.00, 0.00); + validatePeriod(loanDetails, 4, LocalDate.of(2024, 4, 1), null, 0.00, 500.00, 0.00, 500.00, 0.0, 0.0, 0.00, 0.00, 0.00, 0.00, + 8.75, 0.00, 8.75, 0.00, 0.00); + }); + } + private Long applyAndApproveLoanProgressiveAdvancedPaymentAllocationStrategyMonthlyRepayments(Long clientId, Long loanProductId, Integer numberOfRepayments, String loanDisbursementDate, double amount) { LOG.info("------------------------------APPLY AND APPROVE LOAN ---------------------------------------"); From e5f5a6a658396932d350a36f29422db328225871 Mon Sep 17 00:00:00 2001 From: MarianaDmytrivBinariks Date: Fri, 24 Oct 2025 19:32:51 +0300 Subject: [PATCH 28/36] FINERACT-2382: e2e test scenarios for flat cumulative multidisbursal loan --- .../data/loanproduct/DefaultLoanProduct.java | 4 + .../LoanProductGlobalInitializerStep.java | 103 +++++- .../test/stepdef/loan/LoanStepDef.java | 7 + .../fineract/test/support/TestContextKey.java | 4 + .../src/test/resources/features/Loan.feature | 334 ++++++++++++++++++ 5 files changed, 451 insertions(+), 1 deletion(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index e7e8f46743c..3efd89cfafe 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -165,6 +165,10 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_INTEREST_DECL_BAL_SARP_EMI_360_30_NO_INT_RECALC_MULTIDISB_NO_PARTIAL_PERIOD, // LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_MIN_INT_3_MAX_INT_20, // LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_WRITE_OFF_REASON_MAP, // + LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB, // + LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB, // + LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB, // + LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT, // ; @Override diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index 174749caf1c..b5b7e1d5eeb 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -24,6 +24,7 @@ import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_RATE_FREQUENCY_TYPE_MONTH; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_RATE_FREQUENCY_TYPE_WHOLE_TERM; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_RATE_FREQUENCY_TYPE_YEAR; +import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_TYPE_FLAT; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.LOAN_ACCOUNTING_RULE_NONE; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.REPAYMENT_FREQUENCY_TYPE_MONTHS; @@ -1324,7 +1325,7 @@ public void initialize() throws Exception { .recalculationRestFrequencyType(1)// .recalculationRestFrequencyInterval(1)// .repaymentEvery(1)// - .interestRatePerPeriod(7D)// + .interestRatePerPeriod((double) 7.0)// .interestRateFrequencyType(INTEREST_RATE_FREQUENCY_TYPE_MONTH)// .enableDownPayment(false)// .interestRecalculationCompoundingMethod(0)// @@ -3873,6 +3874,106 @@ public void initialize() throws Exception { .createLoanProduct(loanProductsRequestLP2ProgressiveAdvPaymentWriteOffReasonMap).execute(); TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_WRITE_OFF_REASON_MAP, responseLoanProductsRequestLP2ProgressiveAdvPaymentWriteOffReasonMap); + + // LP1 with 12% Flat interest, interest period: Same as repayment, + // Interest recalculation-Same as repayment, Multi-disbursement + String name143 = DefaultLoanProduct.LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB.getName(); + PostLoanProductsRequest loanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursement = loanProductsRequestFactory + .defaultLoanProductsRequestLP1InterestDecliningBalanceDailyRecalculationCompoundingNone()// + .name(name143)// + .interestType(INTEREST_TYPE_FLAT)// + .interestCalculationPeriodType(InterestCalculationPeriodTime.SAME_AS_REPAYMENT_PERIOD.value)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.SAME_AS_REPAYMENT.value)// + .installmentAmountInMultiplesOf(null)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .allowPartialPeriodInterestCalcualtion(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType(OverAppliedCalculationType.PERCENTAGE.value)// + .overAppliedNumber(50);// + Response responseInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursement = loanProductsApi + .createLoanProduct(loanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursement).execute(); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB, + responseInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursement); + + // LP1 with 12% Flat interest, interest period: Same as repayment, + // Interest recalculation-Daily, Multi-disbursement + String name144 = DefaultLoanProduct.LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB.getName(); + PostLoanProductsRequest loanProductsRequestInterestFlatSaRRecalculationDailyMultiDisbursement = loanProductsRequestFactory + .defaultLoanProductsRequestLP1InterestDecliningBalanceDailyRecalculationCompoundingNone()// + .name(name144)// + .interestType(INTEREST_TYPE_FLAT)// + .interestCalculationPeriodType(InterestCalculationPeriodTime.SAME_AS_REPAYMENT_PERIOD.value)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY.value)// + .recalculationRestFrequencyInterval(1)// + .daysInYearType(DaysInYearType.DAYS360.value)// + .daysInMonthType(DaysInMonthType.DAYS30.value)// + .installmentAmountInMultiplesOf(null)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .allowPartialPeriodInterestCalcualtion(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType(OverAppliedCalculationType.PERCENTAGE.value)// + .overAppliedNumber(50);// + Response responseLoanProductsRequestInterestFlatSaRRecalculationDailyMultiDisbursement = loanProductsApi + .createLoanProduct(loanProductsRequestInterestFlatSaRRecalculationDailyMultiDisbursement).execute(); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB, + responseLoanProductsRequestInterestFlatSaRRecalculationDailyMultiDisbursement); + + // LP1 with 12% Flat interest, interest period: Daily, Interest recalculation-Daily, + // Multi-disbursement + String name145 = DefaultLoanProduct.LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB.getName(); + PostLoanProductsRequest loanProductsRequestInterestFlatDailyRecalculationDSameAsRepaymentMultiDisbursement = loanProductsRequestFactory + .defaultLoanProductsRequestLP1InterestDecliningBalanceDailyRecalculationCompoundingNone()// + .name(name145)// + .interestType(INTEREST_TYPE_FLAT)// + .interestCalculationPeriodType(InterestCalculationPeriodTime.DAILY.value)// + .allowPartialPeriodInterestCalcualtion(false)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY.value)// + .recalculationRestFrequencyInterval(1)// + .daysInYearType(DaysInYearType.DAYS360.value)// + .daysInMonthType(DaysInMonthType.DAYS30.value)// + .installmentAmountInMultiplesOf(null)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0);// + Response responseLoanProductsRequestInterestFlatDailyRecalculationDSameAsRepaymentMultiDisbursement = loanProductsApi + .createLoanProduct(loanProductsRequestInterestFlatDailyRecalculationDSameAsRepaymentMultiDisbursement).execute(); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB, + responseLoanProductsRequestInterestFlatDailyRecalculationDSameAsRepaymentMultiDisbursement); + + // LP1 with 12% Flat interest, interest period: Daily, Interest recalculation-Daily + // Multi-disbursement with auto down payment + String name146 = DefaultLoanProduct.LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT.getName(); + PostLoanProductsRequest loanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursementAUtoDownPayment = loanProductsRequestFactory + .defaultLoanProductsRequestLP1InterestDecliningBalanceDailyRecalculationCompoundingNone()// + .name(name146)// + .interestType(INTEREST_TYPE_FLAT)// + .installmentAmountInMultiplesOf(null)// + .interestCalculationPeriodType(InterestCalculationPeriodTime.SAME_AS_REPAYMENT_PERIOD.value)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.SAME_AS_REPAYMENT.value)// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .enableDownPayment(true)// + .enableAutoRepaymentForDownPayment(true)// + .disbursedAmountPercentageForDownPayment(new BigDecimal(25))// + .allowPartialPeriodInterestCalcualtion(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0);// + Response responseLoanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursementAUtoDownPayment = loanProductsApi + .createLoanProduct(loanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursementAUtoDownPayment) + .execute(); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT, + responseLoanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursementAUtoDownPayment); } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index 8aed996dcaa..83d589b1a44 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -2567,6 +2567,13 @@ public void loanInstallmentsObligationsMet() throws IOException { assertThat(allInstallmentsObligationsMet).isTrue(); } + @Then("Loan is closed with zero outstanding balance and it's all installments have obligations met") + public void loanClosedAndInstallmentsObligationsMet() throws IOException { + loanInstallmentsObligationsMet(); + loanOutstanding(0); + loanStatus("CLOSED_OBLIGATIONS_MET"); + } + @Then("Loan closedon_date is {string}") public void loanClosedonDate(String date) throws IOException { Response loanCreateResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index bb8f239fd2a..7a7c906d471 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -184,6 +184,10 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_REFUND_INTEREST_RECALC_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRefundInterestRecalculationAccrualActivity"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_REFUND_INTEREST_RECALC_DOWNPAYMENT_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRefundInterestRecalculatioDownpaymentnAccrualActivity"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_MULTIDISBURSAL_EXPECTS_TRANCHES = "loanProductCreateResponseLP1MultidisbursalThatExpectTranches"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB = "loanProductCreateResponseLP1InterestFlatDailyRecalculationSameAsRepaymentActualActualMultiDisbursement"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB = "loanProductCreateResponseLP1InterestFlatSameAsRepaymentRecalculationDaily36030MultiDisbursement"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB = "loanProductCreateResponseLP1InterestFlatDailyRecalculationDaily36030MultiDisbursement"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT = "loanProductCreateResponseLP1InterestFlatDailyRecalculationSameAsRepaymentMultiDisbursementAutoDownPayment"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_CREATE_RESPONSE = "ChargeForLoanPercentLateCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_AMOUNT_PLUS_INTEREST_CREATE_RESPONSE = "ChargeForLoanPercentLateAmountPlusInterestCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_PROCESSING_CREATE_RESPONSE = "ChargeForLoanPercentProcessingCreateResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature index 01c93201e55..9a356d264fa 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/Loan.feature @@ -8486,3 +8486,337 @@ Feature: Loan When Loan Pay-off is made on "1 February 2025" Then Loan's all installments have obligations met + + @TestRailId:C4118 + Scenario: Verify cumulative multidisb loan with 2nd disb at 1st installment with flat interest type and same_as_repeyment interest calculation period - UC1 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB | 01 January 2025 | 1500 | 7 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | + And Admin successfully approves the loan on "01 January 2025" with "1500" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | +# -- 2nd disb - on Jan, 15, 2025 --# + When Admin sets the business date to "15 January 2025" + When Admin successfully disburse the loan on "15 January 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | | | 15 January 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 1000.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 2 | 28 | 01 March 2025 | | 500.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 3 | 31 | 01 April 2025 | | 0.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 26.25 | 0.0 | 0.0 | 1526.25 | 0.0 | 0.0 | 0.0 | 1526.25 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 15 January 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1500.0 | false | false | +# --- undo last disbursement --- # + When Admin successfully undo last disbursal + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + + When Loan Pay-off is made on "15 January 2025" + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C4119 + Scenario: Verify cumulative multidisbursal loan with 2nd disb at 2nd installment with flat interest type and same_as_repeyment interest calculation period - UC2 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB | 01 January 2025 | 1500 | 7 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | + And Admin successfully approves the loan on "01 January 2025" with "1500" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | +# -- 2nd disb - on Feb, 15, 2025 --# + When Admin sets the business date to "15 February 2025" + When Admin successfully disburse the loan on "15 February 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 8.75 | 0.0 | 0.0 | 342.08 | 0.0 | 0.0 | 0.0 | 342.08 | + | | | 15 February 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 28 | 01 March 2025 | | 666.67 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 3 | 31 | 01 April 2025 | | 0.0 | 666.67 | 8.75 | 0.0 | 0.0 | 675.42 | 0.0 | 0.0 | 0.0 | 675.42 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 26.25 | 0.0 | 0.0 | 1526.25 | 0.0 | 0.0 | 0.0 | 1526.25 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 15 February 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1500.0 | false | false | +# --- undo disbursement --- # + When Admin successfully undo disbursal + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1500.0 | | | 0.0 | | 0.0 | | | | 0.0 | + | 1 | 31 | 01 February 2025 | | 1000.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 2 | 28 | 01 March 2025 | | 500.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 3 | 31 | 01 April 2025 | | 0.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 26.25 | 0.0 | 0.0 | 1526.25 | 0.0 | 0.0 | 0.0 | 1526.25 | + Then Loan Transactions tab has none transaction + + Then Admin can successfully undone the loan approval + Then Loan status will be "SUBMITTED_AND_PENDING_APPROVAL" + And Admin successfully rejects the loan on "15 February 2025" + Then Loan status will be "REJECTED" + + @TestRailId:C4120 + Scenario: Verify cumulative multidisbursal loan with repayment between disbursements with flat interest type and same_as_repeyment interest calculation period - UC3 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_ACTUAL_ACTUAL_MULTIDISB | 01 January 2025 | 1500 | 7 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | + And Admin successfully approves the loan on "01 January 2025" with "1500" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | +# -- repayment on Feb, 1, 2025 ---# + When Admin sets the business date to "01 February 2025" + And Customer makes "AUTOPAY" repayment on "01 February 2025" with 339.16 EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | 01 February 2025 | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 339.16 | 0.0 | 0.0 | 0.0 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 339.16 | 0.0 | 0.0 | 678.34 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 01 February 2025 | Repayment | 339.16 | 333.33 | 5.83 | 0.0 | 0.0 | 666.67 | false | false | +# -- 2nd disb - on Feb, 15, 2025 --# + When Admin sets the business date to "15 February 2025" + When Admin successfully disburse the loan on "15 February 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 8.75 | 0.0 | 0.0 | 342.08 | 339.16 | 0.0 | 0.0 | 2.92 | + | | | 15 February 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 28 | 01 March 2025 | | 666.67 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 3 | 31 | 01 April 2025 | | 0.0 | 666.67 | 8.75 | 0.0 | 0.0 | 675.42 | 0.0 | 0.0 | 0.0 | 675.42 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 26.25 | 0.0 | 0.0 | 1526.25 | 339.16 | 0.0 | 0.0 | 1187.09 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 01 February 2025 | Repayment | 339.16 | 330.41 | 8.75 | 0.0 | 0.0 | 669.59 | false | true | + | 15 February 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1169.59 | false | false | + + When Loan Pay-off is made on "15 February 2025" + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C4121 + Scenario: Verify cumulative multidisbursal loan with flat interest type and same_as_repeyment interest calculation period with down payment - UC4 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | 1st_tranche_disb_expected_date |1st_tranche_disb_principal | 2nd_tranche_disb_expected_date |2nd_tranche_disb_principal | + | LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT | 01 January 2025 | 1500 | 7 | FLAT | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | 01 January 2025 | 1000.0 | 15 January 2025 | 500.0 | + And Admin successfully approves the loan on "01 January 2025" with "1500" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 January 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 31 | 01 February 2025 | | 500.0 | 250.0 | 5.75 | 0.0 | 0.0 | 255.75 | 0.0 | 0.0 | 0.0 | 255.75 | + | 3 | 28 | 01 March 2025 | | 250.0 | 250.0 | 5.75 | 0.0 | 0.0 | 255.75 | 0.0 | 0.0 | 0.0 | 255.75 | + | 4 | 31 | 01 April 2025 | | 0.0 | 250.0 | 5.76 | 0.0 | 0.0 | 255.76 | 0.0 | 0.0 | 0.0 | 255.76 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.26 | 0.0 | 0.0 | 1017.26 | 250.0 | 0.0 | 0.0 | 767.26 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 01 January 2025 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | false | +# -- 2nd disb - on Jan, 15, 2025 --# + When Admin sets the business date to "15 January 2025" + When Admin successfully disburse the loan on "15 January 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2025 | 01 January 2025 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | | | 15 January 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 0 | 15 January 2025 | 15 January 2025 | 1125.0 | 125.0 | 0.0 | 0.0 | 0.0 | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | + | 3 | 31 | 01 February 2025 | | 750.0 | 375.0 | 8.63 | 0.0 | 0.0 | 383.63 | 0.0 | 0.0 | 0.0 | 383.63 | + | 4 | 28 | 01 March 2025 | | 375.0 | 375.0 | 8.63 | 0.0 | 0.0 | 383.63 | 0.0 | 0.0 | 0.0 | 383.63 | + | 5 | 31 | 01 April 2025 | | 0.0 | 375.0 | 8.63 | 0.0 | 0.0 | 383.63 | 0.0 | 0.0 | 0.0 | 383.63 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 25.89 | 0.0 | 0.0 | 1525.89 | 375.0 | 0.0 | 0.0 | 1150.89 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 01 January 2025 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | false | + | 15 January 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1250.0 | false | false | + | 15 January 2025 | Down Payment | 125.0 | 125.0 | 0.0 | 0.0 | 0.0 | 1125.0 | false | false | + + When Loan Pay-off is made on "15 January 2025" + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C4122 + Scenario: Verify cumulative multidisbursal loan with flat interest type and same_as_repeyment interest calculation period with approved over applied amount - UC5 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | 1st_tranche_disb_expected_date |1st_tranche_disb_principal | 2nd_tranche_disb_expected_date |2nd_tranche_disb_principal | + | LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB | 01 January 2025 | 1000 | 7 | FLAT | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | 01 January 2025 | 1000.0 | 15 January 2025 | 500.0 | + And Admin successfully approves the loan on "01 January 2025" with "1200" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | 01 January 2025 | 1000.0 | | +# -- 2nd disb - on Feb, 15, 2025 --# + When Admin sets the business date to "15 January 2025" + When Admin successfully disburse the loan on "15 January 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | | | 15 January 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 1000.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 2 | 28 | 01 March 2025 | | 500.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 3 | 31 | 01 April 2025 | | 0.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 26.25 | 0.0 | 0.0 |1526.25 | 0.0 | 0.0 | 0.0 | 1526.25 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 15 January 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1500.0 | false | false | + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | 01 January 2025 | 1000.0 | | + | 01 January 2025 | 15 January 2025 | 500.0 | | + + When Loan Pay-off is made on "15 January 2025" + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C4123 + Scenario: Verify cumulative multidisbursal loan with undo last disb with flat interest type and daily interest calculation period - UC6 + When Admin sets the business date to "01 January 2025" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | 1st_tranche_disb_expected_date |1st_tranche_disb_principal | 2nd_tranche_disb_expected_date |2nd_tranche_disb_principal | + | LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB | 01 January 2025 | 1500 | 7 | FLAT | DAILY | EQUAL_INSTALLMENTS | 3 | MONTHS | 1 | MONTHS | 3 | 0 | 0 | 0 | PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER | 01 January 2025 | 1000.0 | 15 January 2025 | 500.0 | + And Admin successfully approves the loan on "01 January 2025" with "1500" amount and expected disbursement date on "01 January 2025" + When Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | 01 January 2025 | 1000.0 | | +# -- 2nd disb - on Feb, 15, 2025 --# + When Admin sets the business date to "15 January 2025" + When Admin successfully disburse the loan on "15 January 2025" with "500" EUR transaction amount + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | | | 15 January 2025 | | 500.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 1000.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 2 | 28 | 01 March 2025 | | 500.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + | 3 | 31 | 01 April 2025 | | 0.0 | 500.0 | 8.75 | 0.0 | 0.0 | 508.75 | 0.0 | 0.0 | 0.0 | 508.75 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1500.0 | 26.25 | 0.0 | 0.0 | 1526.25 | 0.0 | 0.0 | 0.0 | 1526.25 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + | 15 January 2025 | Disbursement | 500.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1500.0 | false | false | + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | 01 January 2025 | 1000.0 | | + | 01 January 2025 | 15 January 2025 | 500.0 | | +# --- undo last disbursement --- # + When Admin successfully undo last disbursal + Then Loan Repayment schedule has 3 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2025 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2025 | | 666.67 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 2 | 28 | 01 March 2025 | | 333.34 | 333.33 | 5.83 | 0.0 | 0.0 | 339.16 | 0.0 | 0.0 | 0.0 | 339.16 | + | 3 | 31 | 01 April 2025 | | 0.0 | 333.34 | 5.84 | 0.0 | 0.0 | 339.18 | 0.0 | 0.0 | 0.0 | 339.18 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 17.5 | 0.0 | 0.0 | 1017.5 | 0.0 | 0.0 | 0.0 | 1017.5 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2025 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | false | + Then Loan Tranche Details tab has the following data: + | Expected Disbursement On | Disbursed On | Principal | Net Disbursal Amount | + | 01 January 2025 | 01 January 2025 | 1000.0 | | + + When Loan Pay-off is made on "15 January 2025" + Then Loan is closed with zero outstanding balance and it's all installments have obligations met From cf0a2629d0e0c6195c131c22f8c185ca347e9493 Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Wed, 15 Oct 2025 11:12:16 +0300 Subject: [PATCH 29/36] FINERACT-2385: Zero amount reage transaction should not be allowed --- .../test/stepdef/loan/LoanReAgingStepDef.java | 34 ++++++ .../resources/features/LoanReAging.feature | 103 ++++++++++++++++++ ...edPaymentScheduleTransactionProcessor.java | 5 + .../service/reaging/LoanReAgingValidator.java | 17 +++ .../reaging/LoanReAgingValidatorTest.java | 4 + .../reaging/LoanReAgingIntegrationTest.java | 28 ++--- 6 files changed, 177 insertions(+), 14 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java index 327225d08a8..ff24d6a0eb1 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanReAgingStepDef.java @@ -18,12 +18,15 @@ */ package org.apache.fineract.test.stepdef.loan; +import static org.assertj.core.api.Assertions.assertThat; + import io.cucumber.datatable.DataTable; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; import java.io.IOException; import java.util.List; import lombok.extern.slf4j.Slf4j; +import okhttp3.ResponseBody; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansResponse; @@ -34,6 +37,7 @@ import org.apache.fineract.test.messaging.event.loan.LoanReAgeEvent; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; +import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import retrofit2.Response; @@ -112,4 +116,34 @@ public void checkLoanReAmortizeBusinessEventCreated() { eventAssertion.assertEventRaised(LoanReAgeEvent.class, loanId); } + + @When("Admin fails to create a Loan re-aging transaction with error {string} and with the following data:") + public void adminFailsToCreateReAgingTransactionWithError(final String expectedError, final DataTable table) throws IOException { + final Response loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final long loanId = loanResponse.body().getLoanId(); + + final List data = table.asLists().get(1); + final int frequencyNumber = Integer.parseInt(data.get(0)); + final String frequencyType = data.get(1); + final String startDate = data.get(2); + final int numberOfInstallments = Integer.parseInt(data.get(3)); + + final PostLoansLoanIdTransactionsRequest reAgingRequest = LoanRequestFactory// + .defaultReAgingRequest()// + .frequencyNumber(frequencyNumber)// + .frequencyType(frequencyType)// + .startDate(startDate)// + .numberOfInstallments(numberOfInstallments);// + + final Response response = loanTransactionsApi + .executeLoanTransaction(loanId, reAgingRequest, "reAge").execute(); + + try (ResponseBody errorBody = response.errorBody()) { + Assertions.assertNotNull(errorBody); + assertThat(errorBody.string()).contains(expectedError); + } + + ErrorHelper.checkFailedApiCall(response, 403); + } + } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature index 26763784637..c609009a306 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature @@ -3063,3 +3063,106 @@ Feature: LoanReAging | NSF fee | true | Specified due date | 01 October 2024 | Flat | 20.0 | 0.0 | 0.0 | 20.0 | | NSF fee | true | Specified due date | 01 November 2024 | Flat | 30.0 | 0.0 | 0.0 | 30.0 | + Scenario: Verify that Loan re-aging with zero outstanding balance is rejected in real-time + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 45 | DAYS | 15 | DAYS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "1000" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + When Admin sets the business date to "20 February 2024" + And Customer makes "AUTOPAY" repayment on "20 February 2024" with 750 EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 20 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 20 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 20 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + And Admin adds "LOAN_NSF_FEE" due date charge with "01 March 2024" due date and 10 EUR transaction amount + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 20 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 20 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 20 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 5 | 15 | 01 March 2024 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + When Admin fails to create a Loan re-aging transaction with error "error.msg.loan.reage.no.outstanding.balance.to.reage" and with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 01 March 2024 | 3 | + Then Loan Repayment schedule has 5 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 20 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 20 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 20 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 5 | 15 | 01 March 2024 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 20 February 2024 | Repayment | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + Then Loan Charges tab has the following data: + | Name | isPenalty | Payment due at | Due as of | Calculation type | Due | Paid | Waived | Outstanding | + | NSF fee | true | Specified due date | 01 March 2024 | Flat | 10.0 | 0.0 | 0.0 | 10.0 | + + Scenario: Verify that zero amount reage transaction is reverted during reverse-replay + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 45 | DAYS | 15 | DAYS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "1000" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + When Admin sets the business date to "20 February 2024" + When Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 01 March 2024 | 3 | + Then Loan Repayment schedule has 7 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 5 | 15 | 01 March 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 6 | 31 | 01 April 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 7 | 30 | 01 May 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 20 February 2024 | Re-age | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + When Admin sets the business date to "21 February 2024" + And Customer makes "AUTOPAY" repayment on "19 February 2024" with 750 EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 19 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 19 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 19 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 19 February 2024 | Repayment | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 47e46123149..bc970097997 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -2902,6 +2902,11 @@ private void handleReAge(LoanTransaction loanTransaction, TransactionCtx ctx) { loanTransaction.updateComponentsAndTotal(outstandingPrincipalBalance.get(), Money.zero(currency), Money.zero(currency), Money.zero(currency)); + if (outstandingPrincipalBalance.get().isZero()) { + loanTransaction.reverse(); + return; + } + Money calculatedPrincipal = Money.zero(currency); Money adjustCalculatedPrincipal = Money.zero(currency); if (outstandingPrincipalBalance.get().isGreaterThanZero()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java index dfefabf2865..11419cc0329 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java @@ -51,6 +51,7 @@ public class LoanReAgingValidator { public void validateReAge(Loan loan, JsonCommand command) { validateReAgeRequest(loan, command); validateReAgeBusinessRules(loan); + validateReAgeOutstandingBalance(loan, command); } private void validateReAgeRequest(Loan loan, JsonCommand command) { @@ -160,4 +161,20 @@ private void throwExceptionIfValidationErrorsExist(List dataV private boolean transactionHappenedAfterOther(LoanTransaction transaction, LoanTransaction otherTransaction) { return new ChangeOperation(transaction).compareTo(new ChangeOperation(otherTransaction)) > 0; } + + private void validateReAgeOutstandingBalance(final Loan loan, final JsonCommand command) { + final LocalDate businessDate = getBusinessLocalDate(); + final LocalDate startDate = command.dateValueOfParameterNamed(LoanReAgingApiConstants.startDate); + + final boolean isBackdated = businessDate.isAfter(startDate); + if (isBackdated) { + return; + } + + if (loan.getSummary().getTotalPrincipalOutstanding().compareTo(java.math.BigDecimal.ZERO) == 0) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.reage.no.outstanding.balance.to.reage", + "Loan cannot be re-aged as there are no outstanding balances to be re-aged", loan.getId()); + } + } + } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java index f1f0514378b..4eb4c534bbc 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java @@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; +import org.apache.fineract.portfolio.loanaccount.domain.LoanSummary; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; @@ -516,6 +517,9 @@ private Loan loan() { given(loanProductRelatedDetail.getLoanScheduleType()).willReturn(LoanScheduleType.PROGRESSIVE); given(loan.isInterestBearing()).willReturn(false); given(loan.getLoanTransactions()).willReturn(List.of()); + LoanSummary loanSummary = mock(LoanSummary.class); + given(loan.getSummary()).willReturn(loanSummary); + given(loanSummary.getTotalPrincipalOutstanding()).willReturn(java.math.BigDecimal.valueOf(1000)); return loan; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java index 199c5702f9b..3c32e80617c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java @@ -536,15 +536,15 @@ public void test_LoanReAgeReverseReplay_Works() { long loanId = createdLoanId.get(); PostLoansLoanIdTransactionsResponse repaymentResponse = loanTransactionHelper.makeLoanRepayment(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("02 February 2023").locale("en") - .transactionAmount(250.0)); + .transactionAmount(200.0)); // verify transactions verifyTransactions(loanId, // transaction(500.0, "Disbursement", "01 January 2023"), // transaction(125.0, "Down Payment", "01 January 2023"), // transaction(125.0, "Repayment", "01 February 2023"), // - transaction(250.0, "Repayment", "02 February 2023"), // - transaction(0.0, "Re-age", "27 February 2023") // + transaction(200.0, "Repayment", "02 February 2023"), // + transaction(50.0, "Re-age", "27 February 2023") // ); verifyRepaymentSchedule(loanId, // @@ -552,17 +552,17 @@ public void test_LoanReAgeReverseReplay_Works() { installment(125.0, true, "01 January 2023"), // installment(125.0, true, "16 January 2023"), // installment(125.0, true, "31 January 2023"), // - installment(125.0, true, "15 February 2023"), // - installment(0.0, true, "01 March 2023"), // - installment(0.0, true, "01 April 2023"), // - installment(0.0, true, "01 May 2023"), // - installment(0.0, true, "01 June 2023"), // - installment(0.0, true, "01 July 2023"), // - installment(0.0, true, "01 August 2023") // + installment(75.00, true, "15 February 2023"), // + installment(8.33, false, "01 March 2023"), // + installment(8.33, false, "01 April 2023"), // + installment(8.33, false, "01 May 2023"), // + installment(8.33, false, "01 June 2023"), // + installment(8.33, false, "01 July 2023"), // + installment(8.35, false, "01 August 2023") // ); - checkMaturityDates(loanId, LocalDate.of(2023, 8, 1), LocalDate.of(2023, 2, 2)); + checkMaturityDates(loanId, LocalDate.of(2023, 8, 1), LocalDate.of(2023, 8, 1)); - verifyLoanStatus(loanId, LoanStatus.CLOSED_OBLIGATIONS_MET); + verifyLoanStatus(loanId, LoanStatus.ACTIVE); loanTransactionHelper.reverseLoanTransaction(loanId, repaymentResponse.getResourceId(), new PostLoansLoanIdTransactionsTransactionIdRequest().dateFormat(DATETIME_PATTERN).transactionDate("28 February 2023") @@ -573,7 +573,7 @@ public void test_LoanReAgeReverseReplay_Works() { transaction(500.0, "Disbursement", "01 January 2023"), // transaction(125.0, "Down Payment", "01 January 2023"), // transaction(125.0, "Repayment", "01 February 2023"), // - reversedTransaction(250.0, "Repayment", "02 February 2023"), // + reversedTransaction(200.0, "Repayment", "02 February 2023"), // transaction(250.0, "Re-age", "27 February 2023") // ); @@ -606,7 +606,7 @@ public void test_LoanReAgeReverseReplay_Works() { transaction(500.0, "Disbursement", "01 January 2023"), // transaction(125.0, "Down Payment", "01 January 2023"), // transaction(125.0, "Repayment", "01 February 2023"), // - reversedTransaction(250.0, "Repayment", "02 February 2023"), // + reversedTransaction(200.0, "Repayment", "02 February 2023"), // reversedTransaction(250.0, "Re-age", "27 February 2023") // ); From 2b9102fbe4cefd99bb324a46e9fa87c19f50caf4 Mon Sep 17 00:00:00 2001 From: MarianaDmytrivBinariks Date: Fri, 24 Oct 2025 18:42:13 +0300 Subject: [PATCH 30/36] FINERACT-2385: e2e test scenarios for re-aging disallowed with zero amount --- .../resources/features/LoanReAging.feature | 188 +++++++++++++++++- 1 file changed, 186 insertions(+), 2 deletions(-) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature index c609009a306..cbc43f4dbe8 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanReAging.feature @@ -3063,7 +3063,8 @@ Feature: LoanReAging | NSF fee | true | Specified due date | 01 October 2024 | Flat | 20.0 | 0.0 | 0.0 | 20.0 | | NSF fee | true | Specified due date | 01 November 2024 | Flat | 30.0 | 0.0 | 0.0 | 30.0 | - Scenario: Verify that Loan re-aging with zero outstanding balance is rejected in real-time + @TestRailId:C4136 @AdvancedPaymentAllocation + Scenario: Verify that Loan re-aging with zero outstanding balance is rejected in real-time - UC1 When Admin sets the business date to "01 January 2024" When Admin creates a client with random data When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule @@ -3079,6 +3080,13 @@ Feature: LoanReAging | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | When Admin sets the business date to "20 February 2024" And Customer makes "AUTOPAY" repayment on "20 February 2024" with 750 EUR transaction amount Then Loan Repayment schedule has 4 periods, with the following data for periods: @@ -3088,6 +3096,14 @@ Feature: LoanReAging | 2 | 15 | 16 January 2024 | 20 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 3 | 15 | 31 January 2024 | 20 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 4 | 15 | 15 February 2024 | 20 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 1000.0 | 0.0 | 750.0 | 0.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 20 February 2024 | Repayment | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | And Admin adds "LOAN_NSF_FEE" due date charge with "01 March 2024" due date and 10 EUR transaction amount Then Loan Repayment schedule has 5 periods, with the following data for periods: | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | @@ -3097,6 +3113,14 @@ Feature: LoanReAging | 3 | 15 | 31 January 2024 | 20 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 4 | 15 | 15 February 2024 | 20 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 5 | 15 | 01 March 2024 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 1000.0 | 0.0 | 750.0 | 10.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 20 February 2024 | Repayment | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | When Admin fails to create a Loan re-aging transaction with error "error.msg.loan.reage.no.outstanding.balance.to.reage" and with the following data: | frequencyNumber | frequencyType | startDate | numberOfInstallments | | 1 | MONTHS | 01 March 2024 | 3 | @@ -3108,6 +3132,9 @@ Feature: LoanReAging | 3 | 15 | 31 January 2024 | 20 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 4 | 15 | 15 February 2024 | 20 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 5 | 15 | 01 March 2024 | | 0.0 | 0.0 | 0.0 | 0.0 | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 10.0 | 1010.0 | 1000.0 | 0.0 | 750.0 | 10.0 | Then Loan Transactions tab has the following data: | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | @@ -3117,7 +3144,12 @@ Feature: LoanReAging | Name | isPenalty | Payment due at | Due as of | Calculation type | Due | Paid | Waived | Outstanding | | NSF fee | true | Specified due date | 01 March 2024 | Flat | 10.0 | 0.0 | 0.0 | 10.0 | - Scenario: Verify that zero amount reage transaction is reverted during reverse-replay + When Loan Pay-off is made on "20 February 2024" + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount + + @TestRailId:C4137 @AdvancedPaymentAllocation + Scenario: Verify that zero amount reage transaction is reverted during reverse-replay with backdated repayment that fully paid loan - UC2 When Admin sets the business date to "01 January 2024" When Admin creates a client with random data When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule @@ -3133,6 +3165,13 @@ Feature: LoanReAging | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | When Admin sets the business date to "20 February 2024" When Admin creates a Loan re-aging transaction with the following data: | frequencyNumber | frequencyType | startDate | numberOfInstallments | @@ -3147,6 +3186,9 @@ Feature: LoanReAging | 5 | 15 | 01 March 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | | 6 | 31 | 01 April 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | | 7 | 30 | 01 May 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | Then Loan Transactions tab has the following data: | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | @@ -3154,6 +3196,8 @@ Feature: LoanReAging | 20 February 2024 | Re-age | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | When Admin sets the business date to "21 February 2024" And Customer makes "AUTOPAY" repayment on "19 February 2024" with 750 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount Then Loan Repayment schedule has 4 periods, with the following data for periods: | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | @@ -3161,8 +3205,148 @@ Feature: LoanReAging | 2 | 15 | 16 January 2024 | 19 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 3 | 15 | 31 January 2024 | 19 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | | 4 | 15 | 15 February 2024 | 19 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 1000.0 | 0.0 | 750.0 | 0.0 | Then Loan Transactions tab has the following data: | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | | 19 February 2024 | Repayment | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + + + @TestRailId:C4138 @AdvancedPaymentAllocation + Scenario: Verify that zero amount reage transaction is reverted during reverse-replay with backdated MAR trn that fully paid loan - UC3 + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 45 | DAYS | 15 | DAYS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "1000" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + When Admin sets the business date to "20 February 2024" + When Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 01 March 2024 | 3 | + Then Loan Repayment schedule has 7 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 5 | 15 | 01 March 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 6 | 31 | 01 April 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 7 | 30 | 01 May 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 20 February 2024 | Re-age | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + When Admin sets the business date to "21 February 2024" + And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "19 February 2024" with 750 EUR transaction amount and system-generated Idempotency key + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 19 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 19 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 19 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 1000.0 | 0.0 | 750.0 | 0.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 19 February 2024 | Merchant Issued Refund | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + + @TestRailId:C4139 @AdvancedPaymentAllocation + Scenario: Verify that zero amount reage transaction is reverted during reverse-replay with backdated repayment that overpaid loan - UC4 + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION | 01 January 2024 | 1000 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 45 | DAYS | 15 | DAYS | 3 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2024" with "1000" amount and expected disbursement date on "01 January 2024" + When Admin successfully disburse the loan on "01 January 2024" with "1000" EUR transaction amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 3 | 15 | 31 January 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 4 | 15 | 15 February 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + When Admin sets the business date to "20 February 2024" + When Admin creates a Loan re-aging transaction with the following data: + | frequencyNumber | frequencyType | startDate | numberOfInstallments | + | 1 | MONTHS | 01 March 2024 | 3 | + Then Loan Repayment schedule has 7 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 20 February 2024 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 5 | 15 | 01 March 2024 | | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 6 | 31 | 01 April 2024 | | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + | 7 | 30 | 01 May 2024 | | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 250.0 | 0.0 | 0.0 | 750.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 20 February 2024 | Re-age | 750.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + When Admin sets the business date to "21 February 2024" + And Customer makes "AUTOPAY" repayment on "19 February 2024" with 800 EUR transaction amount + Then Loan status will be "OVERPAID" + And Loan has 50 overpaid amount + Then Loan Repayment schedule has 4 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 01 January 2024 | | 1000.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 0 | 01 January 2024 | 01 January 2024 | 750.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | + | 2 | 15 | 16 January 2024 | 19 February 2024 | 500.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 3 | 15 | 31 January 2024 | 19 February 2024 | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + | 4 | 15 | 15 February 2024 | 19 February 2024 | 0.0 | 250.0 | 0.0 | 0.0 | 0.0 | 250.0 | 250.0 | 0.0 | 250.0 | 0.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 1000.0 | 0.0 | 0.0 | 0.0 | 1000.0 | 1000.0 | 0.0 | 750.0 | 0.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 01 January 2024 | Disbursement | 1000.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1000.0 | false | + | 01 January 2024 | Down Payment | 250.0 | 250.0 | 0.0 | 0.0 | 0.0 | 750.0 | false | + | 19 February 2024 | Repayment | 800.0 | 750.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + + When Admin makes Credit Balance Refund transaction on "21 February 2024" with 50 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount From 0b575dd3d68e4821cdb337334f7923a79c6f1aff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 05:16:48 +0000 Subject: [PATCH 31/36] Bump actions/upload-artifact from 4.6.2 to 5.0.0 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...330a01c490aca151604b8cf639adc76d48f6c5d4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build-cucumber.yml | 6 +++--- .github/workflows/build-e2e-tests.yml | 6 +++--- .github/workflows/build-mariadb.yml | 4 ++-- .github/workflows/build-mysql.yml | 4 ++-- .github/workflows/build-postgresql.yml | 4 ++-- .../run-integration-test-sequentially-postgresql.yml | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-cucumber.yml b/.github/workflows/build-cucumber.yml index 8c419153bb3..11618f7f2cf 100644 --- a/.github/workflows/build-cucumber.yml +++ b/.github/workflows/build-cucumber.yml @@ -87,7 +87,7 @@ jobs: - name: Archive test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: test-results-${{ matrix.task }} path: | @@ -98,7 +98,7 @@ jobs: - name: Archive Progressive Loan JAR if: matrix.job_type == 'progressive-loan' && always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: progressive-loan-jar path: ${{ env.EMBEDDABLE_JAR_FILE }} @@ -107,7 +107,7 @@ jobs: - name: Archive server logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: server-logs-${{ matrix.task }} path: '**/build/cargo/' diff --git a/.github/workflows/build-e2e-tests.yml b/.github/workflows/build-e2e-tests.yml index 9d65fd047c0..96b9078ebc9 100644 --- a/.github/workflows/build-e2e-tests.yml +++ b/.github/workflows/build-e2e-tests.yml @@ -146,7 +146,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: allure-results-shard-${{ matrix.shard_index }} path: | @@ -159,7 +159,7 @@ jobs: - name: Upload Allure Report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: allure-report-shard-${{ matrix.shard_index }} path: allure-report-shard-${{ matrix.shard_index }} @@ -167,7 +167,7 @@ jobs: - name: Upload logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: logs-shard-${{ matrix.shard_index }} path: | diff --git a/.github/workflows/build-mariadb.yml b/.github/workflows/build-mariadb.yml index 4ce5ef4f861..c8b52a34396 100644 --- a/.github/workflows/build-mariadb.yml +++ b/.github/workflows/build-mariadb.yml @@ -135,7 +135,7 @@ jobs: - name: Archive test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: test-results-${{ matrix.task }} path: '**/build/reports/' @@ -143,7 +143,7 @@ jobs: - name: Archive server logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: server-logs-${{ matrix.task }} path: '**/build/cargo/' diff --git a/.github/workflows/build-mysql.yml b/.github/workflows/build-mysql.yml index 426cf389fa9..9d6bb4ce8e8 100644 --- a/.github/workflows/build-mysql.yml +++ b/.github/workflows/build-mysql.yml @@ -135,7 +135,7 @@ jobs: - name: Archive test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: test-results-${{ matrix.task }} path: '**/build/reports/' @@ -143,7 +143,7 @@ jobs: - name: Archive server logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: server-logs-${{ matrix.task }} path: '**/build/cargo/' diff --git a/.github/workflows/build-postgresql.yml b/.github/workflows/build-postgresql.yml index 5629d15156a..04f3d4ae605 100644 --- a/.github/workflows/build-postgresql.yml +++ b/.github/workflows/build-postgresql.yml @@ -136,7 +136,7 @@ jobs: - name: Archive test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: test-results-${{ matrix.task }} path: '**/build/reports/' @@ -144,7 +144,7 @@ jobs: - name: Archive server logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: server-logs-${{ matrix.task }} path: '**/build/cargo/' diff --git a/.github/workflows/run-integration-test-sequentially-postgresql.yml b/.github/workflows/run-integration-test-sequentially-postgresql.yml index 15cb8722186..9919438fae4 100644 --- a/.github/workflows/run-integration-test-sequentially-postgresql.yml +++ b/.github/workflows/run-integration-test-sequentially-postgresql.yml @@ -84,7 +84,7 @@ jobs: ./gradlew --no-daemon --console=plain :oauth2-tests:test -PdbType=postgresql - name: Archive test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: test-results retention-days: 5 @@ -95,7 +95,7 @@ jobs: oauth2-tests/build/reports/ - name: Archive server logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: name: server-logs retention-days: 5 From 4a1cf9fcc4b0d44b0362a47bf86c5bbb1b8f1f1a Mon Sep 17 00:00:00 2001 From: MarianaDmytrivBinariks Date: Mon, 20 Oct 2025 18:31:32 +0300 Subject: [PATCH 32/36] FINERACT-2390: e2e test scenarios for infinite loop with MIR trn issue --- .../data/loanproduct/DefaultLoanProduct.java | 1 + .../LoanProductGlobalInitializerStep.java | 82 +++++ .../fineract/test/support/TestContextKey.java | 1 + .../features/LoanAccrualActivity.feature | 297 +++++++++++++++++- 4 files changed, 380 insertions(+), 1 deletion(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 3efd89cfafe..1fe4813d907 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -169,6 +169,7 @@ public enum DefaultLoanProduct implements LoanProduct { LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB, // LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB, // LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT, // + LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY, // ; @Override diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index b5b7e1d5eeb..f2803e8f306 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -19,6 +19,7 @@ package org.apache.fineract.test.initializer.global; import static org.apache.fineract.client.models.LoanProductRelatedDetail.DaysInYearCustomStrategyEnum.FEB_29_PERIOD_ONLY; +import static org.apache.fineract.test.data.ChargeOffBehaviour.ZERO_INTEREST; import static org.apache.fineract.test.data.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_CALCULATION_PERIOD_TYPE_SAME_AS_REPAYMENT; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.INTEREST_RATE_FREQUENCY_TYPE_MONTH; @@ -3974,6 +3975,87 @@ public void initialize() throws Exception { TestContext.INSTANCE.set( TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT, responseLoanProductsRequestInterestFlatSaRRecalculationSameAsRepaymentMultiDisbursementAUtoDownPayment); + + // LP2 advanced custom payment allocation + progressive loan schedule + horizontal + interest recalculation + // Frequency for recalculate Outstanding Principal: Daily, Frequency Interval for recalculation: 1 + String name147 = DefaultLoanProduct.LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY + .getName(); + PostLoanProductsRequest loanProductsResponseAdvCustomPaymentAllocationProgressiveLoanInterestDailyEmi36030InterestRecalculationDaily = loanProductsRequestFactory + .defaultLoanProductsRequestLP2Emi()// + .supportedInterestRefundTypes(supportedInterestRefundTypes) + // .installmentAmountInMultiplesOf(null) // + .name(name147)// + .daysInYearType(DaysInYearType.DAYS360.value)// + .daysInMonthType(DaysInMonthType.DAYS30.value)// + .isInterestRecalculationEnabled(true)// + .preClosureInterestCalculationStrategy(1)// + .rescheduleStrategyMethod(4)// + .interestRecalculationCompoundingMethod(0)// + .recalculationRestFrequencyType(2)// + .recalculationRestFrequencyInterval(1)// + .enableAccrualActivityPosting(true) // + .chargeOffBehaviour(ZERO_INTEREST.value)// + .paymentAllocation(List.of(// + createPaymentAllocation("MERCHANT_ISSUED_REFUND", "LAST_INSTALLMENT", + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_FEE), // + createPaymentAllocation("GOODWILL_CREDIT", "REAMORTIZATION", + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_FEE), // + createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT", + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_FEE), // + createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT", + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_FEE, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PRINCIPAL, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PENALTY, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_FEE) // + ));// + Response responseLoanProductsResponseAdvCustomPaymentAllocationProgressiveLoanInterestDailyEmi36030InterestRecalculationDaily = loanProductsApi + .createLoanProduct( + loanProductsResponseAdvCustomPaymentAllocationProgressiveLoanInterestDailyEmi36030InterestRecalculationDaily) + .execute(); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY, + responseLoanProductsResponseAdvCustomPaymentAllocationProgressiveLoanInterestDailyEmi36030InterestRecalculationDaily); } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 7a7c906d471..37c72d2d345 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -188,6 +188,7 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_DAILY_360_30_APPROVED_OVER_APPLIED_MULTIDISB = "loanProductCreateResponseLP1InterestFlatSameAsRepaymentRecalculationDaily36030MultiDisbursement"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB = "loanProductCreateResponseLP1InterestFlatDailyRecalculationDaily36030MultiDisbursement"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT = "loanProductCreateResponseLP1InterestFlatDailyRecalculationSameAsRepaymentMultiDisbursementAutoDownPayment"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY = "loanProductCreateResponseLP2AdvancedPaymentAllocCustomInterestDailyEmi36030InterestRecalculationDaily"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_CREATE_RESPONSE = "ChargeForLoanPercentLateCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_AMOUNT_PLUS_INTEREST_CREATE_RESPONSE = "ChargeForLoanPercentLateAmountPlusInterestCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_PROCESSING_CREATE_RESPONSE = "ChargeForLoanPercentProcessingCreateResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature index 812004fbe8a..4d74b356e5d 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanAccrualActivity.feature @@ -9090,6 +9090,301 @@ Feature: LoanAccrualActivity And In Loan Transactions all transactions have non-null external-id When Admin makes Credit Balance Refund transaction on "06 September 2025" with 3.91 EUR transaction amount - Then Loan's all installments have obligations met + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C3960 + Scenario: Verify MIR trn is processed after repayment for progressive loan with custom payment allocation rules - UC1 + When Admin sets the business date to "16 August 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY | 16 August 2025 | 516.06 | 19.99 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 24 | MONTHS | 1 | MONTHS | 24 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "16 August 2025" with "516.06" amount and expected disbursement date on "16 August 2025" + And Admin successfully disburse the loan on "16 August 2025" with "516.06" EUR transaction amount + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 16 August 2025 | | 516.06 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 16 September 2025 | | 498.4 | 17.66 | 8.6 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 2 | 30 | 16 October 2025 | | 480.44 | 17.96 | 8.3 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 3 | 31 | 16 November 2025 | | 462.18 | 18.26 | 8.0 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 4 | 30 | 16 December 2025 | | 443.62 | 18.56 | 7.7 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 5 | 31 | 16 January 2026 | | 424.75 | 18.87 | 7.39 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 6 | 31 | 16 February 2026 | | 405.57 | 19.18 | 7.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 7 | 28 | 16 March 2026 | | 386.07 | 19.5 | 6.76 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 8 | 31 | 16 April 2026 | | 366.24 | 19.83 | 6.43 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 9 | 30 | 16 May 2026 | | 346.08 | 20.16 | 6.1 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 10 | 31 | 16 June 2026 | | 325.59 | 20.49 | 5.77 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 11 | 30 | 16 July 2026 | | 304.75 | 20.84 | 5.42 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 12 | 31 | 16 August 2026 | | 283.57 | 21.18 | 5.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 13 | 31 | 16 September 2026 | | 262.03 | 21.54 | 4.72 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 14 | 30 | 16 October 2026 | | 240.13 | 21.9 | 4.36 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 15 | 31 | 16 November 2026 | | 217.87 | 22.26 | 4.0 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 16 | 30 | 16 December 2026 | | 195.24 | 22.63 | 3.63 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 17 | 31 | 16 January 2027 | | 172.23 | 23.01 | 3.25 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 18 | 31 | 16 February 2027 | | 148.84 | 23.39 | 2.87 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 19 | 28 | 16 March 2027 | | 125.06 | 23.78 | 2.48 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 20 | 31 | 16 April 2027 | | 100.88 | 24.18 | 2.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 21 | 30 | 16 May 2027 | | 76.3 | 24.58 | 1.68 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 22 | 31 | 16 June 2027 | | 51.31 | 24.99 | 1.27 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 23 | 30 | 16 July 2027 | | 25.9 | 25.41 | 0.85 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 24 | 31 | 16 August 2027 | | 0.0 | 25.9 | 0.43 | 0.0 | 0.0 | 26.33 | 0.0 | 0.0 | 0.0 | 26.33 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 516.06 | 114.25 | 0.0 | 0.0 | 630.31 | 0.0 | 0.0 | 0.0 | 630.31 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + + When Admin sets the business date to "08 September 2025" + And Admin makes "REPAYMENT" transaction with "AUTOPAY" payment type on "08 September 2025" with 50 EUR transaction amount + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 16 August 2025 | | 516.06 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 16 September 2025 | 08 September 2025 | 496.18 | 19.88 | 6.38 | 0.0 | 0.0 | 26.26 | 26.26 | 26.26 | 0.0 | 0.0 | + | 2 | 30 | 16 October 2025 | | 469.92 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | 23.74 | 23.74 | 0.0 | 2.52 | + | 3 | 31 | 16 November 2025 | | 461.39 | 8.53 | 17.73 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 4 | 30 | 16 December 2025 | | 442.82 | 18.57 | 7.69 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 5 | 31 | 16 January 2026 | | 423.94 | 18.88 | 7.38 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 6 | 31 | 16 February 2026 | | 404.74 | 19.2 | 7.06 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 7 | 28 | 16 March 2026 | | 385.22 | 19.52 | 6.74 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 8 | 31 | 16 April 2026 | | 365.38 | 19.84 | 6.42 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 9 | 30 | 16 May 2026 | | 345.21 | 20.17 | 6.09 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 10 | 31 | 16 June 2026 | | 324.7 | 20.51 | 5.75 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 11 | 30 | 16 July 2026 | | 303.85 | 20.85 | 5.41 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 12 | 31 | 16 August 2026 | | 282.65 | 21.2 | 5.06 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 13 | 31 | 16 September 2026 | | 261.1 | 21.55 | 4.71 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 14 | 30 | 16 October 2026 | | 239.19 | 21.91 | 4.35 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 15 | 31 | 16 November 2026 | | 216.91 | 22.28 | 3.98 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 16 | 30 | 16 December 2026 | | 194.26 | 22.65 | 3.61 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 17 | 31 | 16 January 2027 | | 171.24 | 23.02 | 3.24 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 18 | 31 | 16 February 2027 | | 147.83 | 23.41 | 2.85 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 19 | 28 | 16 March 2027 | | 124.03 | 23.8 | 2.46 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 20 | 31 | 16 April 2027 | | 99.84 | 24.19 | 2.07 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 21 | 30 | 16 May 2027 | | 75.24 | 24.6 | 1.66 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 22 | 31 | 16 June 2027 | | 50.23 | 25.01 | 1.25 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 23 | 30 | 16 July 2027 | | 24.81 | 25.42 | 0.84 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 24 | 31 | 16 August 2027 | | 0.0 | 24.81 | 0.41 | 0.0 | 0.0 | 25.22 | 0.0 | 0.0 | 0.0 | 25.22 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 516.06 | 113.14 | 0.0 | 0.0 | 629.2 | 50.0 | 50.0 | 0.0 | 579.2 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + | 08 September 2025 | Repayment | 50.0 | 43.62 | 6.38 | 0.0 | 0.0 | 472.44 | false | false | + + When Admin sets the business date to "06 October 2025" + When Admin makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "06 October 2025" with 516.06 EUR transaction amount + + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + | 08 September 2025 | Repayment | 50.0 | 43.62 | 6.38 | 0.0 | 0.0 | 472.44 | false | false | + | 16 September 2025 | Accrual Activity | 6.38 | 0.0 | 6.38 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Merchant Issued Refund | 516.06 | 472.44 | 7.28 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Interest Refund | 13.66 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Accrual | 13.66 | 0.0 | 13.66 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Accrual Activity | 7.28 | 0.0 | 7.28 | 0.0 | 0.0 | 0.0 | false | false | + + And Loan status will be "OVERPAID" + And Loan has 50 overpaid amount + + When Admin makes Credit Balance Refund transaction on "06 October 2025" with 50 EUR transaction amount + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C4128 + Scenario: Verify Payout Refund trn is processed after repayment for progressive loan with custom payment allocation rules - UC2 + When Admin sets the business date to "16 August 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY | 16 August 2025 | 516.06 | 19.99 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 24 | MONTHS | 1 | MONTHS | 24 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "16 August 2025" with "516.06" amount and expected disbursement date on "16 August 2025" + And Admin successfully disburse the loan on "16 August 2025" with "516.06" EUR transaction amount + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 16 August 2025 | | 516.06 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 16 September 2025 | | 498.4 | 17.66 | 8.6 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 2 | 30 | 16 October 2025 | | 480.44 | 17.96 | 8.3 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 3 | 31 | 16 November 2025 | | 462.18 | 18.26 | 8.0 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 4 | 30 | 16 December 2025 | | 443.62 | 18.56 | 7.7 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 5 | 31 | 16 January 2026 | | 424.75 | 18.87 | 7.39 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 6 | 31 | 16 February 2026 | | 405.57 | 19.18 | 7.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 7 | 28 | 16 March 2026 | | 386.07 | 19.5 | 6.76 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 8 | 31 | 16 April 2026 | | 366.24 | 19.83 | 6.43 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 9 | 30 | 16 May 2026 | | 346.08 | 20.16 | 6.1 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 10 | 31 | 16 June 2026 | | 325.59 | 20.49 | 5.77 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 11 | 30 | 16 July 2026 | | 304.75 | 20.84 | 5.42 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 12 | 31 | 16 August 2026 | | 283.57 | 21.18 | 5.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 13 | 31 | 16 September 2026 | | 262.03 | 21.54 | 4.72 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 14 | 30 | 16 October 2026 | | 240.13 | 21.9 | 4.36 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 15 | 31 | 16 November 2026 | | 217.87 | 22.26 | 4.0 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 16 | 30 | 16 December 2026 | | 195.24 | 22.63 | 3.63 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 17 | 31 | 16 January 2027 | | 172.23 | 23.01 | 3.25 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 18 | 31 | 16 February 2027 | | 148.84 | 23.39 | 2.87 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 19 | 28 | 16 March 2027 | | 125.06 | 23.78 | 2.48 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 20 | 31 | 16 April 2027 | | 100.88 | 24.18 | 2.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 21 | 30 | 16 May 2027 | | 76.3 | 24.58 | 1.68 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 22 | 31 | 16 June 2027 | | 51.31 | 24.99 | 1.27 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 23 | 30 | 16 July 2027 | | 25.9 | 25.41 | 0.85 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 24 | 31 | 16 August 2027 | | 0.0 | 25.9 | 0.43 | 0.0 | 0.0 | 26.33 | 0.0 | 0.0 | 0.0 | 26.33 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 516.06 | 114.25 | 0.0 | 0.0 | 630.31 | 0.0 | 0.0 | 0.0 | 630.31 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + + When Admin sets the business date to "08 September 2025" + And Admin makes "REPAYMENT" transaction with "AUTOPAY" payment type on "08 September 2025" with 50 EUR transaction amount + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 16 August 2025 | | 516.06 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 16 September 2025 | 08 September 2025 | 496.18 | 19.88 | 6.38 | 0.0 | 0.0 | 26.26 | 26.26 | 26.26 | 0.0 | 0.0 | + | 2 | 30 | 16 October 2025 | | 469.92 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | 23.74 | 23.74 | 0.0 | 2.52 | + | 3 | 31 | 16 November 2025 | | 461.39 | 8.53 | 17.73 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 4 | 30 | 16 December 2025 | | 442.82 | 18.57 | 7.69 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 5 | 31 | 16 January 2026 | | 423.94 | 18.88 | 7.38 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 6 | 31 | 16 February 2026 | | 404.74 | 19.2 | 7.06 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 7 | 28 | 16 March 2026 | | 385.22 | 19.52 | 6.74 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 8 | 31 | 16 April 2026 | | 365.38 | 19.84 | 6.42 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 9 | 30 | 16 May 2026 | | 345.21 | 20.17 | 6.09 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 10 | 31 | 16 June 2026 | | 324.7 | 20.51 | 5.75 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 11 | 30 | 16 July 2026 | | 303.85 | 20.85 | 5.41 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 12 | 31 | 16 August 2026 | | 282.65 | 21.2 | 5.06 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 13 | 31 | 16 September 2026 | | 261.1 | 21.55 | 4.71 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 14 | 30 | 16 October 2026 | | 239.19 | 21.91 | 4.35 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 15 | 31 | 16 November 2026 | | 216.91 | 22.28 | 3.98 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 16 | 30 | 16 December 2026 | | 194.26 | 22.65 | 3.61 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 17 | 31 | 16 January 2027 | | 171.24 | 23.02 | 3.24 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 18 | 31 | 16 February 2027 | | 147.83 | 23.41 | 2.85 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 19 | 28 | 16 March 2027 | | 124.03 | 23.8 | 2.46 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 20 | 31 | 16 April 2027 | | 99.84 | 24.19 | 2.07 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 21 | 30 | 16 May 2027 | | 75.24 | 24.6 | 1.66 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 22 | 31 | 16 June 2027 | | 50.23 | 25.01 | 1.25 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 23 | 30 | 16 July 2027 | | 24.81 | 25.42 | 0.84 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 24 | 31 | 16 August 2027 | | 0.0 | 24.81 | 0.41 | 0.0 | 0.0 | 25.22 | 0.0 | 0.0 | 0.0 | 25.22 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 516.06 | 113.14 | 0.0 | 0.0 | 629.2 | 50.0 | 50.0 | 0.0 | 579.2 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + | 08 September 2025 | Repayment | 50.0 | 43.62 | 6.38 | 0.0 | 0.0 | 472.44 | false | false | + + When Admin sets the business date to "06 October 2025" + When Admin makes "PAYOUT_REFUND" transaction with "AUTOPAY" payment type on "06 October 2025" with 516.06 EUR transaction amount + + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + | 08 September 2025 | Repayment | 50.0 | 43.62 | 6.38 | 0.0 | 0.0 | 472.44 | false | false | + | 16 September 2025 | Accrual Activity | 6.38 | 0.0 | 6.38 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Payout Refund | 516.06 | 472.44 | 7.28 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Interest Refund | 13.66 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Accrual | 13.66 | 0.0 | 13.66 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Accrual Activity | 7.28 | 0.0 | 7.28 | 0.0 | 0.0 | 0.0 | false | false | + + And Loan status will be "OVERPAID" + And Loan has 50 overpaid amount + + When Admin makes Credit Balance Refund transaction on "06 October 2025" with 50 EUR transaction amount + Then Loan is closed with zero outstanding balance and it's all installments have obligations met + + @TestRailId:C4129 + Scenario: Verify Goodwill Credit trn is processed after repayment for progressive loan with custom payment allocation rules - UC3 + When Admin sets the business date to "16 August 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY | 16 August 2025 | 516.06 | 19.99 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 24 | MONTHS | 1 | MONTHS | 24 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "16 August 2025" with "516.06" amount and expected disbursement date on "16 August 2025" + And Admin successfully disburse the loan on "16 August 2025" with "516.06" EUR transaction amount + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 16 August 2025 | | 516.06 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 16 September 2025 | | 498.4 | 17.66 | 8.6 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 2 | 30 | 16 October 2025 | | 480.44 | 17.96 | 8.3 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 3 | 31 | 16 November 2025 | | 462.18 | 18.26 | 8.0 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 4 | 30 | 16 December 2025 | | 443.62 | 18.56 | 7.7 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 5 | 31 | 16 January 2026 | | 424.75 | 18.87 | 7.39 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 6 | 31 | 16 February 2026 | | 405.57 | 19.18 | 7.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 7 | 28 | 16 March 2026 | | 386.07 | 19.5 | 6.76 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 8 | 31 | 16 April 2026 | | 366.24 | 19.83 | 6.43 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 9 | 30 | 16 May 2026 | | 346.08 | 20.16 | 6.1 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 10 | 31 | 16 June 2026 | | 325.59 | 20.49 | 5.77 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 11 | 30 | 16 July 2026 | | 304.75 | 20.84 | 5.42 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 12 | 31 | 16 August 2026 | | 283.57 | 21.18 | 5.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 13 | 31 | 16 September 2026 | | 262.03 | 21.54 | 4.72 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 14 | 30 | 16 October 2026 | | 240.13 | 21.9 | 4.36 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 15 | 31 | 16 November 2026 | | 217.87 | 22.26 | 4.0 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 16 | 30 | 16 December 2026 | | 195.24 | 22.63 | 3.63 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 17 | 31 | 16 January 2027 | | 172.23 | 23.01 | 3.25 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 18 | 31 | 16 February 2027 | | 148.84 | 23.39 | 2.87 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 19 | 28 | 16 March 2027 | | 125.06 | 23.78 | 2.48 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 20 | 31 | 16 April 2027 | | 100.88 | 24.18 | 2.08 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 21 | 30 | 16 May 2027 | | 76.3 | 24.58 | 1.68 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 22 | 31 | 16 June 2027 | | 51.31 | 24.99 | 1.27 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 23 | 30 | 16 July 2027 | | 25.9 | 25.41 | 0.85 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 24 | 31 | 16 August 2027 | | 0.0 | 25.9 | 0.43 | 0.0 | 0.0 | 26.33 | 0.0 | 0.0 | 0.0 | 26.33 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 516.06 | 114.25 | 0.0 | 0.0 | 630.31 | 0.0 | 0.0 | 0.0 | 630.31 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + + When Admin sets the business date to "08 September 2025" + And Admin makes "REPAYMENT" transaction with "AUTOPAY" payment type on "08 September 2025" with 50 EUR transaction amount + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 16 August 2025 | | 516.06 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 16 September 2025 | 08 September 2025 | 496.18 | 19.88 | 6.38 | 0.0 | 0.0 | 26.26 | 26.26 | 26.26 | 0.0 | 0.0 | + | 2 | 30 | 16 October 2025 | | 469.92 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | 23.74 | 23.74 | 0.0 | 2.52 | + | 3 | 31 | 16 November 2025 | | 461.39 | 8.53 | 17.73 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 4 | 30 | 16 December 2025 | | 442.82 | 18.57 | 7.69 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 5 | 31 | 16 January 2026 | | 423.94 | 18.88 | 7.38 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 6 | 31 | 16 February 2026 | | 404.74 | 19.2 | 7.06 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 7 | 28 | 16 March 2026 | | 385.22 | 19.52 | 6.74 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 8 | 31 | 16 April 2026 | | 365.38 | 19.84 | 6.42 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 9 | 30 | 16 May 2026 | | 345.21 | 20.17 | 6.09 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 10 | 31 | 16 June 2026 | | 324.7 | 20.51 | 5.75 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 11 | 30 | 16 July 2026 | | 303.85 | 20.85 | 5.41 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 12 | 31 | 16 August 2026 | | 282.65 | 21.2 | 5.06 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 13 | 31 | 16 September 2026 | | 261.1 | 21.55 | 4.71 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 14 | 30 | 16 October 2026 | | 239.19 | 21.91 | 4.35 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 15 | 31 | 16 November 2026 | | 216.91 | 22.28 | 3.98 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 16 | 30 | 16 December 2026 | | 194.26 | 22.65 | 3.61 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 17 | 31 | 16 January 2027 | | 171.24 | 23.02 | 3.24 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 18 | 31 | 16 February 2027 | | 147.83 | 23.41 | 2.85 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 19 | 28 | 16 March 2027 | | 124.03 | 23.8 | 2.46 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 20 | 31 | 16 April 2027 | | 99.84 | 24.19 | 2.07 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 21 | 30 | 16 May 2027 | | 75.24 | 24.6 | 1.66 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 22 | 31 | 16 June 2027 | | 50.23 | 25.01 | 1.25 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 23 | 30 | 16 July 2027 | | 24.81 | 25.42 | 0.84 | 0.0 | 0.0 | 26.26 | 0.0 | 0.0 | 0.0 | 26.26 | + | 24 | 31 | 16 August 2027 | | 0.0 | 24.81 | 0.41 | 0.0 | 0.0 | 25.22 | 0.0 | 0.0 | 0.0 | 25.22 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 516.06 | 113.14 | 0.0 | 0.0 | 629.2 | 50.0 | 50.0 | 0.0 | 579.2 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + | 08 September 2025 | Repayment | 50.0 | 43.62 | 6.38 | 0.0 | 0.0 | 472.44 | false | false | + + When Admin sets the business date to "06 October 2025" + When Admin makes "GOODWILL_CREDIT" transaction with "AUTOPAY" payment type on "06 October 2025" with 516.06 EUR transaction amount + + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 16 August 2025 | Disbursement | 516.06 | 0.0 | 0.0 | 0.0 | 0.0 | 516.06 | false | false | + | 08 September 2025 | Repayment | 50.0 | 43.62 | 6.38 | 0.0 | 0.0 | 472.44 | false | false | + | 16 September 2025 | Accrual Activity | 6.38 | 0.0 | 6.38 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Goodwill Credit | 516.06 | 472.44 | 7.28 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Accrual | 13.66 | 0.0 | 13.66 | 0.0 | 0.0 | 0.0 | false | false | + | 06 October 2025 | Accrual Activity | 7.28 | 0.0 | 7.28 | 0.0 | 0.0 | 0.0 | false | false | + + And Loan status will be "OVERPAID" + And Loan has 36.34 overpaid amount + When Admin makes Credit Balance Refund transaction on "06 October 2025" with 36.34 EUR transaction amount + Then Loan is closed with zero outstanding balance and it's all installments have obligations met From a06b6d86186a86eecd0f3c324bab7b32b9a9df05 Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Mon, 27 Oct 2025 12:41:04 +0100 Subject: [PATCH 33/36] FINERACT-2390: Fix `infinite loop` issue --- .../test/helper/ErrorMessageHelper.java | 2 +- .../LoanProductGlobalInitializerStep.java | 6 +- .../features/LoanMerchantIssuedRefund.feature | 88 +++++++++++++++++++ .../calc/ProgressiveEMICalculator.java | 15 ++++ 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index afa922b09c4..5f69aed55b1 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -545,7 +545,7 @@ public static String wrongValueInLineInRepaymentSchedule(String resourceId, int List expected) { String actual = actualList.stream().map(Object::toString).collect(Collectors.joining(System.lineSeparator())); return String.format("%nWrong value in Repayment schedule of resource %s tab line %s." // - + "%nActual values in line (with the same due date) are: %n%s - But expected values in line: %n%s", resourceId, line, + + "%nActual values in line (with the same due date) are: %n%s - %nBut expected values in line: %n%s", resourceId, line, actual, expected); } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index f2803e8f306..72f61001355 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -4010,16 +4010,16 @@ public void initialize() throws Exception { LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PENALTY, // LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_FEE), // createPaymentAllocation("GOODWILL_CREDIT", "REAMORTIZATION", - LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PRINCIPAL, // LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PRINCIPAL, // LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_PENALTY, // LoanProductPaymentAllocationRule.AllocationTypesEnum.PAST_DUE_FEE, // - LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PRINCIPAL, // LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PRINCIPAL, // LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_PENALTY, // LoanProductPaymentAllocationRule.AllocationTypesEnum.DUE_FEE, // - LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PRINCIPAL, // LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_INTEREST, // + LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PRINCIPAL, // LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_PENALTY, // LoanProductPaymentAllocationRule.AllocationTypesEnum.IN_ADVANCE_FEE), // createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT", diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanMerchantIssuedRefund.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanMerchantIssuedRefund.feature index 2302f3a8347..9c3952ae646 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanMerchantIssuedRefund.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanMerchantIssuedRefund.feature @@ -549,3 +549,91 @@ Feature: MerchantIssuedRefund When Admin sets the business date to "16 July 2024" #mismatch date for Interest Refund When Admin manually adds Interest Refund for "MERCHANT_ISSUED_REFUND" transaction made on invalid date "16 July 2024" with 2.42 EUR interest refund amount + + + @TestRailId:C4127 + Scenario: High interest rate in advance paid Repayment + Merchant Issued Refund + When Admin sets the business date to "10 July 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 10 July 2025 | 1000 | 24.99 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 24 | MONTHS | 1 | MONTHS | 24 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "10 July 2025" with "1000" amount and expected disbursement date on "10 July 2025" + And Admin successfully disburse the loan on "10 July 2025" with "733.56" EUR transaction amount + When Admin sets the business date to "29 July 2025" + And Customer makes "REPAYMENT" transaction with "AUTOPAY" payment type on "29 July 2025" with 540.0 EUR transaction amount and system-generated Idempotency key + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 10 July 2025 | | 733.56 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 10 August 2025 | 29 July 2025 | 703.77 | 29.79 | 9.36 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 2 | 31 | 10 September 2025 | 29 July 2025 | 664.62 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 3 | 30 | 10 October 2025 | 29 July 2025 | 625.47 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 4 | 31 | 10 November 2025 | 29 July 2025 | 586.32 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 5 | 30 | 10 December 2025 | 29 July 2025 | 547.17 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 6 | 31 | 10 January 2026 | 29 July 2025 | 508.02 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 7 | 31 | 10 February 2026 | 29 July 2025 | 468.87 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 8 | 28 | 10 March 2026 | 29 July 2025 | 429.72 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 9 | 31 | 10 April 2026 | 29 July 2025 | 390.57 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 10 | 30 | 10 May 2026 | 29 July 2025 | 351.42 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 11 | 31 | 10 June 2026 | 29 July 2025 | 312.27 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 12 | 30 | 10 July 2026 | 29 July 2025 | 273.12 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 13 | 31 | 10 August 2026 | 29 July 2025 | 233.97 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 14 | 31 | 10 September 2026 | | 194.82 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 31.05 | 31.05 | 0.0 | 8.1 | + | 15 | 30 | 10 October 2026 | | 194.82 | 0.0 | 39.15 | 0.0 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | + | 16 | 31 | 10 November 2026 | | 181.27 | 13.55 | 25.6 | 0.0 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | + | 17 | 30 | 10 December 2026 | | 145.89 | 35.38 | 3.77 | 0.0 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | + | 18 | 31 | 10 January 2027 | | 109.78 | 36.11 | 3.04 | 0.0 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | + | 19 | 31 | 10 February 2027 | | 72.92 | 36.86 | 2.29 | 0.0 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | + | 20 | 28 | 10 March 2027 | | 35.29 | 37.63 | 1.52 | 0.0 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | + | 21 | 31 | 10 April 2027 | | 0.0 | 35.29 | 0.73 | 0.0 | 0.0 | 36.02 | 0.0 | 0.0 | 0.0 | 36.02 | + | 22 | 30 | 10 May 2027 | 29 July 2025 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 23 | 31 | 10 June 2027 | 29 July 2025 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 24 | 30 | 10 July 2027 | 29 July 2025 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 733.56 | 85.46 | 0.0 | 0.0 | 819.02 | 540.0 | 540.0 | 0.0 | 279.02 | + And Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 10 July 2025 | Disbursement | 733.56 | 0.0 | 0.0 | 0.0 | 0.0 | 733.56 | false | false | + | 29 July 2025 | Repayment | 540.0 | 530.64 | 9.36 | 0.0 | 0.0 | 202.92 | false | false | + When Admin sets the business date to "02 October 2025" + And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "02 October 2025" with 635.23 EUR transaction amount and system-generated Idempotency key and interestRefundCalculation true + Then Loan Repayment schedule has 24 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 10 July 2025 | | 733.56 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 10 August 2025 | 29 July 2025 | 703.77 | 29.79 | 9.36 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 2 | 31 | 10 September 2025 | 29 July 2025 | 664.62 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 3 | 30 | 10 October 2025 | 29 July 2025 | 625.47 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 4 | 31 | 10 November 2025 | 29 July 2025 | 586.32 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 5 | 30 | 10 December 2025 | 29 July 2025 | 547.17 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 6 | 31 | 10 January 2026 | 29 July 2025 | 508.02 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 7 | 31 | 10 February 2026 | 29 July 2025 | 468.87 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 8 | 28 | 10 March 2026 | 29 July 2025 | 429.72 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 9 | 31 | 10 April 2026 | 29 July 2025 | 390.57 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 10 | 30 | 10 May 2026 | 29 July 2025 | 351.42 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 11 | 31 | 10 June 2026 | 29 July 2025 | 312.27 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 12 | 30 | 10 July 2026 | 29 July 2025 | 273.12 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 13 | 31 | 10 August 2026 | 29 July 2025 | 233.97 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 14 | 31 | 10 September 2026 | 02 October 2025 | 202.92 | 31.05 | 0.0 | 0.0 | 0.0 | 31.05 | 31.05 | 31.05 | 0.0 | 0.0 | + | 15 | 30 | 10 October 2026 | 02 October 2025 | 202.92 | 0.0 | 8.97 | 0.0 | 0.0 | 8.97 | 8.97 | 8.97 | 0.0 | 0.0 | + | 16 | 31 | 10 November 2026 | 02 October 2025 | 202.92 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 17 | 30 | 10 December 2026 | 02 October 2025 | 202.92 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 18 | 31 | 10 January 2027 | 02 October 2025 | 202.92 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | + | 19 | 31 | 10 February 2027 | 02 October 2025 | 195.75 | 7.17 | 0.0 | 0.0 | 0.0 | 7.17 | 7.17 | 7.17 | 0.0 | 0.0 | + | 20 | 28 | 10 March 2027 | 02 October 2025 | 156.6 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 21 | 31 | 10 April 2027 | 02 October 2025 | 117.45 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 22 | 30 | 10 May 2027 | 02 October 2025 | 78.3 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 23 | 31 | 10 June 2027 | 02 October 2025 | 39.15 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + | 24 | 30 | 10 July 2027 | 02 October 2025 | 0.0 | 39.15 | 0.0 | 0.0 | 0.0 | 39.15 | 39.15 | 39.15 | 0.0 | 0.0 | + And Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 733.56 | 18.33 | 0.0 | 0.0 | 751.89 | 751.89 | 751.89 | 0.0 | 0.0 | + And Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 10 July 2025 | Disbursement | 733.56 | 0.0 | 0.0 | 0.0 | 0.0 | 733.56 | false | false | + | 29 July 2025 | Repayment | 540.0 | 530.64 | 9.36 | 0.0 | 0.0 | 202.92 | false | false | + | 10 August 2025 | Accrual Activity | 9.36 | 0.0 | 9.36 | 0.0 | 0.0 | 0.0 | false | false | + | 02 October 2025 | Merchant Issued Refund | 635.23 | 202.92 | 8.97 | 0.0 | 0.0 | 0.0 | false | false | + | 02 October 2025 | Interest Refund | 17.07 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | false | + | 02 October 2025 | Accrual | 18.33 | 0.0 | 18.33 | 0.0 | 0.0 | 0.0 | false | false | + | 02 October 2025 | Accrual Activity | 8.97 | 0.0 | 8.97 | 0.0 | 0.0 | 0.0 | false | false | diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index b007774176f..055b6fe2cd2 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -560,6 +560,21 @@ private void calculateEMIValueAndRateFactorsForDecliningBalanceInterestMethod(fi } private void calculateLastUnpaidRepaymentPeriodEMI(ProgressiveLoanInterestScheduleModel scheduleModel, LocalDate tillDate) { + + Money totalDuePaidDiff = scheduleModel.getTotalDuePrincipal().minus(scheduleModel.getTotalPaidPrincipal()); + // Remove outstanding principal from EMI in case outstanding principal is greater than total due minus paid + // diff. We need this extra step in case excessive principal was paid with LAST_INSTALLMENT strategy + scheduleModel.repaymentPeriods().forEach(rp -> { + if (rp.getOutstandingPrincipal().isGreaterThan(totalDuePaidDiff)) { + Money delta = rp.getOutstandingPrincipal().minus(totalDuePaidDiff); + rp.setEmi(rp.getEmi().minus(delta)); + Money minimumEMI = MathUtil.plus(rp.getPaidInterest(), rp.getPaidPrincipal()); + if (rp.getEmi().isLessThan(minimumEMI)) { + rp.setEmi(minimumEMI); + } + } + }); + Optional findLastUnpaidRepaymentPeriod = scheduleModel.repaymentPeriods().stream().filter(rp -> !rp.isFullyPaid()) .reduce((first, second) -> second); From cda6fa64e4bbcdb1dd450ddcf8fd2493a5fcf08d Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Tue, 21 Oct 2025 15:41:36 +0300 Subject: [PATCH 34/36] FINERACT-2389: Fix the handling of nullable field overrides --- .../data/loanproduct/DefaultLoanProduct.java | 2 + .../LoanProductGlobalInitializerStep.java | 49 +++++++ .../loan/LoanOverrideFieldsStepDef.java | 120 ++++++++++++++++++ .../fineract/test/support/TestContextKey.java | 2 + .../features/LoanOverrideFileds.feature | 58 +++++++++ .../api/LoansApiResourceSwagger.java | 10 ++ .../service/LoanScheduleAssembler.java | 30 +++-- .../BaseLoanIntegrationTest.java | 30 +++-- .../DelinquencyActionIntegrationTests.java | 10 +- .../common/loans/LoanProductTestBuilder.java | 4 +- ...nitiateExternalAssetOwnerTransferTest.java | 4 +- 11 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOverrideFieldsStepDef.java create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 1fe4813d907..851365f5a5a 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -170,6 +170,8 @@ public enum DefaultLoanProduct implements LoanProduct { LP1_INTEREST_FLAT_DAILY_RECALCULATION_DAILY_360_30_MULTIDISB, // LP1_INTEREST_FLAT_SAR_RECALCULATION_SAME_AS_REPAYMENT_MULTIDISB_AUTO_DOWNPAYMENT, // LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY, // + LP1_WITH_OVERRIDES, // + LP1_NO_OVERRIDES // ; @Override diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index 72f61001355..f9251b541b9 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -36,6 +36,7 @@ import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.apache.fineract.client.models.AdvancedPaymentData; +import org.apache.fineract.client.models.AllowAttributeOverrides; import org.apache.fineract.client.models.CreditAllocationData; import org.apache.fineract.client.models.CreditAllocationOrder; import org.apache.fineract.client.models.LoanProductChargeData; @@ -4056,6 +4057,54 @@ public void initialize() throws Exception { TestContext.INSTANCE.set( TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY, responseLoanProductsResponseAdvCustomPaymentAllocationProgressiveLoanInterestDailyEmi36030InterestRecalculationDaily); + + // (LP1_WITH_OVERRIDES) - Loan product with all attribute overrides ENABLED + final String nameWithOverrides = DefaultLoanProduct.LP1_WITH_OVERRIDES.getName(); + final PostLoanProductsRequest loanProductsRequestWithOverrides = loanProductsRequestFactory.defaultLoanProductsRequestLP1() // + .name(nameWithOverrides) // + .interestRatePerPeriod(1.0) // + .maxInterestRatePerPeriod(30.0) // + .inArrearsTolerance(10) // + .graceOnPrincipalPayment(1) // + .graceOnInterestPayment(1) // + .graceOnArrearsAgeing(3) // + .numberOfRepayments(6) // + .allowAttributeOverrides(new AllowAttributeOverrides() // + .amortizationType(true) // + .interestType(true) // + .transactionProcessingStrategyCode(true) // + .interestCalculationPeriodType(true) // + .inArrearsTolerance(true) // + .repaymentEvery(true) // + .graceOnPrincipalAndInterestPayment(true) // + .graceOnArrearsAgeing(true)); + final Response responseWithOverrides = loanProductsApi.createLoanProduct(loanProductsRequestWithOverrides) + .execute(); + TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_WITH_OVERRIDES, responseWithOverrides); + + // (LP1_NO_OVERRIDES) - Loan product with all attribute overrides DISABLED + final String nameNoOverrides = DefaultLoanProduct.LP1_NO_OVERRIDES.getName(); + final PostLoanProductsRequest loanProductsRequestNoOverrides = loanProductsRequestFactory.defaultLoanProductsRequestLP1() // + .name(nameNoOverrides) // + .interestRatePerPeriod(1.0) // + .maxInterestRatePerPeriod(30.0) // + .inArrearsTolerance(10) // + .graceOnPrincipalPayment(1) // + .graceOnInterestPayment(1) // + .graceOnArrearsAgeing(3) // + .numberOfRepayments(6) // + .allowAttributeOverrides(new AllowAttributeOverrides() // + .amortizationType(false) // + .interestType(false) // + .transactionProcessingStrategyCode(false) // + .interestCalculationPeriodType(false) // + .inArrearsTolerance(false) // + .repaymentEvery(false) // + .graceOnPrincipalAndInterestPayment(false) // + .graceOnArrearsAgeing(false)); + final Response responseNoOverrides = loanProductsApi.createLoanProduct(loanProductsRequestNoOverrides) + .execute(); + TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_NO_OVERRIDES, responseNoOverrides); } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOverrideFieldsStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOverrideFieldsStepDef.java new file mode 100644 index 00000000000..4f8185c9bb7 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanOverrideFieldsStepDef.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.test.stepdef.loan; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PostClientsResponse; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.client.services.LoansApi; +import org.apache.fineract.test.data.loanproduct.DefaultLoanProduct; +import org.apache.fineract.test.data.loanproduct.LoanProductResolver; +import org.apache.fineract.test.factory.LoanRequestFactory; +import org.apache.fineract.test.helper.ErrorHelper; +import org.apache.fineract.test.stepdef.AbstractStepDef; +import org.apache.fineract.test.support.TestContextKey; +import retrofit2.Response; + +@RequiredArgsConstructor +public class LoanOverrideFieldsStepDef extends AbstractStepDef { + + private final LoanRequestFactory loanRequestFactory; + private final LoanProductResolver loanProductResolver; + private final LoansApi loansApi; + + @Then("LoanDetails has {string} field with value: {string}") + public void checkLoanDetailsFieldWithValue(final String fieldName, final String expectedValue) throws IOException { + final Response loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + assertNotNull(loanResponse.body()); + final Long loanId = loanResponse.body().getLoanId(); + + final Response loanDetails = loansApi.retrieveLoan(loanId, false, "", "", "").execute(); + ErrorHelper.checkSuccessfulApiCall(loanDetails); + assertNotNull(loanDetails.body()); + + verifyFieldValue(loanDetails.body(), fieldName, expectedValue); + } + + private void verifyFieldValue(final GetLoansLoanIdResponse loanDetails, final String fieldName, final String expectedValue) { + final Integer actualValue = getIntFieldValue(loanDetails, fieldName); + final Integer expected = Integer.valueOf(expectedValue); + assertThat(actualValue).as("Expected %s to be %d but was %s", fieldName, expected, actualValue).isEqualTo(expected); + } + + private Integer getIntFieldValue(final GetLoansLoanIdResponse loanDetails, final String fieldName) { + return switch (fieldName) { + case "inArrearsTolerance" -> loanDetails.getInArrearsTolerance(); + case "graceOnPrincipalPayment" -> loanDetails.getGraceOnPrincipalPayment(); + case "graceOnInterestPayment" -> loanDetails.getGraceOnInterestPayment(); + case "graceOnArrearsAgeing" -> loanDetails.getGraceOnArrearsAgeing(); + default -> throw new IllegalArgumentException("Unknown override field: " + fieldName); + }; + } + + @When("Admin creates a new Loan with the following override data:") + public void createLoanWithOverrideData(final DataTable dataTable) throws IOException { + final Response clientResponse = testContext().get(TestContextKey.CLIENT_CREATE_RESPONSE); + assertNotNull(clientResponse.body()); + final Long clientId = clientResponse.body().getClientId(); + + final Map overrideData = dataTable.asMap(String.class, String.class); + + final String loanProductName = overrideData.get("loanProduct"); + if (loanProductName == null) { + throw new IllegalArgumentException("loanProduct is required in override data"); + } + + final PostLoansRequest loansRequest = loanRequestFactory.defaultLoansRequest(clientId) + .productId(loanProductResolver.resolve(DefaultLoanProduct.valueOf(loanProductName))).numberOfRepayments(6) + .loanTermFrequency(180).interestRatePerPeriod(new BigDecimal(1)); + + overrideData.forEach((fieldName, value) -> { + if (!"loanProduct".equals(fieldName)) { + applyOverrideField(loansRequest, fieldName, value); + } + }); + + final Response response = loansApi.calculateLoanScheduleOrSubmitLoanApplication(loansRequest, "").execute(); + testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + ErrorHelper.checkSuccessfulApiCall(response); + } + + private void applyOverrideField(final PostLoansRequest request, final String fieldName, final String value) { + final boolean isNull = "null".equals(value); + + switch (fieldName) { + case "inArrearsTolerance" -> request.inArrearsTolerance(isNull ? null : new BigDecimal(value)); + case "graceOnInterestPayment" -> request.graceOnInterestPayment(isNull ? null : Integer.valueOf(value)); + case "graceOnPrincipalPayment" -> request.graceOnPrincipalPayment(isNull ? null : Integer.valueOf(value)); + case "graceOnArrearsAgeing" -> request.graceOnArrearsAgeing(isNull ? null : Integer.valueOf(value)); + default -> throw new IllegalArgumentException("Unknown override field: " + fieldName); + } + } + +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 37c72d2d345..0f56c55464c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -275,4 +275,6 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_INTEREST_RECALC_360_30_MULTIDISB_OVER_APPLIED_EXPECTED_TRANCHES = "loanProductCreateResponseLP2ProgressiveAdvancedPaymentInterestRecalculationMultidisbursalApprovedOverAppliedAmountExpectedTransches"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_DAILY_TILL_PRECLOSE_MIN_INT_3_MAX_INT_20 = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyTillPreCloseMinInt3MaxInt20"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_PROGRESSIVE_ADV_PYMNT_WRITE_OFF_REASON_MAP = "loanProductCreateResponseLP2ProgressiveAdvancedPaymentWriteOffReasonMap"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_WITH_OVERRIDES = "loanProductCreateResponseLP1WithOverrides"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP1_NO_OVERRIDES = "loanProductCreateResponseLP1NoOverrides"; } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature new file mode 100644 index 00000000000..b955d0f272c --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature @@ -0,0 +1,58 @@ +@LoanOverrideFields +Feature: LoanOverrideFields + + Scenario: Verify that all nullable fields default to product when overrides not allowed and not provided + When Admin sets the business date to the actual date + When Admin creates a client with random data + When Admin creates a new Loan with the following override data: + | loanProduct | LP1_NO_OVERRIDES | + | inArrearsTolerance | null | + | graceOnPrincipalPayment | null | + | graceOnInterestPayment | null | + | graceOnArrearsAgeing | null | + Then LoanDetails has "inArrearsTolerance" field with value: "10" + Then LoanDetails has "graceOnPrincipalPayment" field with value: "1" + Then LoanDetails has "graceOnInterestPayment" field with value: "1" + Then LoanDetails has "graceOnArrearsAgeing" field with value: "3" + + Scenario: Verify that all nullable fields ignore overrides when overrides not allowed + When Admin sets the business date to the actual date + When Admin creates a client with random data + When Admin creates a new Loan with the following override data: + | loanProduct | LP1_NO_OVERRIDES | + | inArrearsTolerance | 11 | + | graceOnPrincipalPayment | 2 | + | graceOnInterestPayment | 2 | + | graceOnArrearsAgeing | 4 | + Then LoanDetails has "inArrearsTolerance" field with value: "10" + Then LoanDetails has "graceOnPrincipalPayment" field with value: "1" + Then LoanDetails has "graceOnInterestPayment" field with value: "1" + Then LoanDetails has "graceOnArrearsAgeing" field with value: "3" + + Scenario: Verify that nullable fields default to product when override is allowed but not provided + When Admin sets the business date to the actual date + When Admin creates a client with random data + When Admin creates a new Loan with the following override data: + | loanProduct | LP1_WITH_OVERRIDES | + | inArrearsTolerance | null | + | graceOnPrincipalPayment | null | + | graceOnInterestPayment | null | + | graceOnArrearsAgeing | null | + Then LoanDetails has "inArrearsTolerance" field with value: "10" + Then LoanDetails has "graceOnPrincipalPayment" field with value: "1" + Then LoanDetails has "graceOnInterestPayment" field with value: "1" + Then LoanDetails has "graceOnArrearsAgeing" field with value: "3" + + Scenario: Verify that nullable fields default to product when override is allowed and provided + When Admin sets the business date to the actual date + When Admin creates a client with random data + When Admin creates a new Loan with the following override data: + | loanProduct | LP1_WITH_OVERRIDES | + | inArrearsTolerance | 11 | + | graceOnPrincipalPayment | 2 | + | graceOnInterestPayment | 2 | + | graceOnArrearsAgeing | 4 | + Then LoanDetails has "inArrearsTolerance" field with value: "11" + Then LoanDetails has "graceOnPrincipalPayment" field with value: "2" + Then LoanDetails has "graceOnInterestPayment" field with value: "2" + Then LoanDetails has "graceOnArrearsAgeing" field with value: "4" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index 3ca327b710d..40d97e3fc49 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -1216,6 +1216,14 @@ private GetLoansLoanIdLoanTermEnumData() {} public Boolean chargedOff; @Schema(example = "3") public Integer inArrearsTolerance; + @Schema(example = "0") + public Integer graceOnPrincipalPayment; + @Schema(example = "0") + public Integer graceOnInterestPayment; + @Schema(example = "0") + public Integer graceOnInterestCharged; + @Schema(example = "3") + public Integer graceOnArrearsAgeing; @Schema(example = "false") public Boolean enableDownPayment; @Schema(example = "0.000000") @@ -1348,6 +1356,8 @@ private PostLoansRequest() {} public Integer graceOnInterestPayment; @Schema(example = "1") public Integer graceOnArrearsAgeing; + @Schema(example = "10") + public BigDecimal inArrearsTolerance; @Schema(example = "HORIZONTAL") public String loanScheduleProcessingType; @Schema(example = "false") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java index c0742533d95..bb303f52b66 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java @@ -350,27 +350,33 @@ private LoanApplicationTerms assembleLoanApplicationTermsFrom(final JsonElement } // grace details - final Integer graceOnPrincipalPayment = allowOverridingGraceOnPrincipalAndInterestPayment - ? this.fromApiJsonHelper.extractIntegerWithLocaleNamed("graceOnPrincipalPayment", element) - : loanProduct.getLoanProductRelatedDetail().getGraceOnPrincipalPayment(); + Integer graceOnPrincipalPayment = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("graceOnPrincipalPayment", element); + if (!allowOverridingGraceOnPrincipalAndInterestPayment || graceOnPrincipalPayment == null) { + graceOnPrincipalPayment = loanProduct.getLoanProductRelatedDetail().getGraceOnPrincipalPayment(); + } final Integer recurringMoratoriumOnPrincipalPeriods = this.fromApiJsonHelper .extractIntegerWithLocaleNamed("recurringMoratoriumOnPrincipalPeriods", element); - final Integer graceOnInterestPayment = allowOverridingGraceOnPrincipalAndInterestPayment - ? this.fromApiJsonHelper.extractIntegerWithLocaleNamed("graceOnInterestPayment", element) - : loanProduct.getLoanProductRelatedDetail().getGraceOnInterestPayment(); + Integer graceOnInterestPayment = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("graceOnInterestPayment", element); + if (!allowOverridingGraceOnPrincipalAndInterestPayment || graceOnInterestPayment == null) { + graceOnInterestPayment = loanProduct.getLoanProductRelatedDetail().getGraceOnInterestPayment(); + } final Integer graceOnInterestCharged = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("graceOnInterestCharged", element); final LocalDate interestChargedFromDate = this.fromApiJsonHelper.extractLocalDateNamed("interestChargedFromDate", element); final Boolean isInterestChargedFromDateSameAsDisbursalDateEnabled = this.configurationDomainService .isInterestChargedFromDateSameAsDisbursementDate(); - final Integer graceOnArrearsAgeing = allowOverridingGraceOnArrearsAging - ? this.fromApiJsonHelper.extractIntegerWithLocaleNamed(LoanProductConstants.GRACE_ON_ARREARS_AGEING_PARAMETER_NAME, element) - : loanProduct.getLoanProductRelatedDetail().getGraceOnArrearsAgeing(); + Integer graceOnArrearsAgeing = this.fromApiJsonHelper + .extractIntegerWithLocaleNamed(LoanProductConstants.GRACE_ON_ARREARS_AGEING_PARAMETER_NAME, element); + if (!allowOverridingGraceOnArrearsAging || graceOnArrearsAgeing == null) { + graceOnArrearsAgeing = loanProduct.getLoanProductRelatedDetail().getGraceOnArrearsAgeing(); + } // other - final BigDecimal inArrearsTolerance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("inArrearsTolerance", element); - final Money inArrearsToleranceMoney = allowOverridingArrearsTolerance ? Money.of(currency, inArrearsTolerance) - : loanProduct.getLoanProductRelatedDetail().getInArrearsTolerance(); + BigDecimal inArrearsTolerance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("inArrearsTolerance", element); + if (!allowOverridingArrearsTolerance || inArrearsTolerance == null) { + inArrearsTolerance = loanProduct.getLoanProductRelatedDetail().getInArrearsTolerance().getAmount(); + } + final Money inArrearsToleranceMoney = Money.of(currency, inArrearsTolerance); final BigDecimal emiAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LoanApiConstants.fixedEmiAmountParameterName, element); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index ba07af2d609..08bacb5170b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -886,7 +886,8 @@ protected PostLoanProductsRequest create1InstallmentAmountInMultiplesOf4Period1M .repaymentEvery(1)// .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue())// .interestType(interestType)// - .amortizationType(amortizationType); + .amortizationType(amortizationType)// + .graceOnArrearsAgeing(0); } private RequestSpecification createRequestSpecification(String authKey) { @@ -1350,13 +1351,26 @@ protected PostLoansRequest applyLoanRequest(Long clientId, Long loanProductId, S protected PostLoansRequest applyLoanRequest(Long clientId, Long loanProductId, String loanDisbursementDate, Double amount, int numberOfRepayments, Consumer customizer) { - PostLoansRequest postLoansRequest = new PostLoansRequest().clientId(clientId).productId(loanProductId) - .expectedDisbursementDate(loanDisbursementDate).dateFormat(DATETIME_PATTERN) - .transactionProcessingStrategyCode(DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY) - .locale("en").submittedOnDate(loanDisbursementDate).amortizationType(1).interestRatePerPeriod(BigDecimal.ZERO) - .interestCalculationPeriodType(1).interestType(0).repaymentEvery(30).repaymentFrequencyType(0) - .numberOfRepayments(numberOfRepayments).loanTermFrequency(numberOfRepayments * 30).loanTermFrequencyType(0) - .maxOutstandingLoanBalance(BigDecimal.valueOf(amount)).principal(BigDecimal.valueOf(amount)).loanType("individual"); + PostLoansRequest postLoansRequest = new PostLoansRequest().clientId(clientId) // + .productId(loanProductId) // + .expectedDisbursementDate(loanDisbursementDate) // + .dateFormat(DATETIME_PATTERN) // + .transactionProcessingStrategyCode(DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_STRATEGY) // + .locale("en") // + .submittedOnDate(loanDisbursementDate) // + .amortizationType(1) // + .interestRatePerPeriod(BigDecimal.ZERO) // + .interestCalculationPeriodType(1) // + .interestType(0) // + .repaymentEvery(30) // + .repaymentFrequencyType(0) // + .numberOfRepayments(numberOfRepayments) // + .loanTermFrequency(numberOfRepayments * 30) // + .loanTermFrequencyType(0) // + .maxOutstandingLoanBalance(BigDecimal.valueOf(amount)) // + .principal(BigDecimal.valueOf(amount)) // + .loanType("individual") // + .graceOnArrearsAgeing(0); if (customizer != null) { customizer.accept(postLoansRequest); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java index 168a029c8b4..12be1252a11 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/DelinquencyActionIntegrationTests.java @@ -285,15 +285,15 @@ public void testVerifyLoanDelinquencyRecalculationForBackdatedPauseDelinquencyAc verifyLoanDelinquencyData(loanId, 6, new InstallmentDelinquencyData(4, 10, BigDecimal.valueOf(250.0))); // Create Delinquency Pause for the Loan in the past - PostLoansDelinquencyActionResponse response = loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, - "27 January 2023", "15 February 2023"); + loanTransactionHelper.createLoanDelinquencyAction(loanId, PAUSE, "27 January 2023", "15 February 2023"); List loanDelinquencyActions = loanTransactionHelper.getLoanDelinquencyActions(loanId); Assertions.assertNotNull(loanDelinquencyActions); Assertions.assertEquals(1, loanDelinquencyActions.size()); - Assertions.assertEquals("PAUSE", loanDelinquencyActions.get(0).getAction()); - Assertions.assertEquals(LocalDate.parse("27 January 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getStartDate()); - Assertions.assertEquals(LocalDate.parse("15 February 2023", dateTimeFormatter), loanDelinquencyActions.get(0).getEndDate()); + Assertions.assertEquals("PAUSE", loanDelinquencyActions.getFirst().getAction()); + Assertions.assertEquals(LocalDate.parse("27 January 2023", dateTimeFormatter), + loanDelinquencyActions.getFirst().getStartDate()); + Assertions.assertEquals(LocalDate.parse("15 February 2023", dateTimeFormatter), loanDelinquencyActions.getFirst().getEndDate()); // Loan delinquency data calculation after backdated pause verifyLoanDelinquencyData(loanId, 3, new InstallmentDelinquencyData(1, 3, BigDecimal.valueOf(250.0))); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java index 0ae33fa693d..8ae4bcc1dfe 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java @@ -137,8 +137,8 @@ public class LoanProductTestBuilder { private String minimumGuaranteeFromOwnFunds = null; private String minimumGuaranteeFromGuarantor = null; private String isArrearsBasedOnOriginalSchedule = null; - private String graceOnPrincipalPayment = "1"; - private String graceOnInterestPayment = "1"; + private String graceOnPrincipalPayment = null; + private String graceOnInterestPayment = null; private JsonObject allowAttributeOverrides = null; private Boolean allowPartialPeriodInterestCalcualtion = false; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java index 7848cf6adf3..4b7f3f92c37 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/investor/externalassetowner/InitiateExternalAssetOwnerTransferTest.java @@ -1368,8 +1368,8 @@ private Integer applyForLoanApplication(final String clientID, final String loan .withLoanTermFrequencyAsMonths().withNumberOfRepayments("4").withRepaymentEveryAfter("1") .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("2").withAmortizationTypeAsEqualInstallments() .withInterestTypeAsDecliningBalance().withInterestCalculationPeriodTypeSameAsRepaymentPeriod() - .withExpectedDisbursementDate(date).withSubmittedOnDate(date).withCollaterals(collaterals) - .build(clientID, loanProductID, null); + .withExpectedDisbursementDate(date).withSubmittedOnDate(date).withCollaterals(collaterals).withInArrearsTolerance("0") + .withPrincipalGrace("0").withInterestGrace("0").build(clientID, loanProductID, null); return LOAN_TRANSACTION_HELPER.getLoanId(loanApplicationJSON); } From 4bef5644e0f8095edf60b225696c61c6398cd2a3 Mon Sep 17 00:00:00 2001 From: Peter Kovacs Date: Tue, 28 Oct 2025 10:25:06 +0100 Subject: [PATCH 35/36] FINERACT-2389: Fix the handling of nullable field overrides - E2E tests --- .../test/stepdef/loan/LoanStepDef.java | 80 +++++++++++++++++++ .../features/LoanDelinquency.feature | 32 ++++++++ .../features/LoanOverrideFileds.feature | 4 + 3 files changed, 116 insertions(+) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index 83d589b1a44..b4664fe7210 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -714,6 +714,12 @@ public void createFullyCustomizedLoanWithInterestRateFrequencyType(final DataTab createFullyCustomizedLoanWithInterestRateFrequency(data.get(1)); } + @When("Admin creates a fully customized loan with graceOnArrearsAgeing and following data:") + public void createFullyCustomizedLoanWithGraceOnArrearsAgeing(final DataTable table) throws IOException { + final List> data = table.asLists(); + createFullyCustomizedLoanWithGraceOnArrearsAgeing(data.get(1)); + } + @When("Admin creates a fully customized loan with charges and following data:") public void createFullyCustomizedLoanWithLoanCharges(final DataTable table) throws IOException { final List> data = table.asLists(); @@ -3758,6 +3764,80 @@ public void createFullyCustomizedLoanWithInterestRateFrequency(final List loanData) throws IOException { + final String loanProduct = loanData.get(0); + final String submitDate = loanData.get(1); + final String principal = loanData.get(2); + final BigDecimal interestRate = new BigDecimal(loanData.get(3)); + final String interestTypeStr = loanData.get(4); + final String interestCalculationPeriodStr = loanData.get(5); + final String amortizationTypeStr = loanData.get(6); + final Integer loanTermFrequency = Integer.valueOf(loanData.get(7)); + final String loanTermFrequencyType = loanData.get(8); + final Integer repaymentFrequency = Integer.valueOf(loanData.get(9)); + final String repaymentFrequencyTypeStr = loanData.get(10); + final Integer numberOfRepayments = Integer.valueOf(loanData.get(11)); + final Integer graceOnPrincipalPayment = Integer.valueOf(loanData.get(12)); + final Integer graceOnInterestPayment = Integer.valueOf(loanData.get(13)); + final Integer graceOnInterestCharged = Integer.valueOf(loanData.get(14)); + final String transactionProcessingStrategyCode = loanData.get(15); + final String graceOnArrearsAgeingStr = loanData.get(16); + + final Response clientResponse = testContext().get(TestContextKey.CLIENT_CREATE_RESPONSE); + final Long clientId = clientResponse.body().getClientId(); + + final DefaultLoanProduct product = DefaultLoanProduct.valueOf(loanProduct); + final Long loanProductId = loanProductResolver.resolve(product); + + final LoanTermFrequencyType termFrequencyType = LoanTermFrequencyType.valueOf(loanTermFrequencyType); + final Integer loanTermFrequencyTypeValue = termFrequencyType.getValue(); + + final RepaymentFrequencyType repaymentFrequencyType = RepaymentFrequencyType.valueOf(repaymentFrequencyTypeStr); + final Integer repaymentFrequencyTypeValue = repaymentFrequencyType.getValue(); + + final InterestType interestType = InterestType.valueOf(interestTypeStr); + final Integer interestTypeValue = interestType.getValue(); + + final InterestCalculationPeriodTime interestCalculationPeriod = InterestCalculationPeriodTime.valueOf(interestCalculationPeriodStr); + final Integer interestCalculationPeriodValue = interestCalculationPeriod.getValue(); + + final AmortizationType amortizationType = AmortizationType.valueOf(amortizationTypeStr); + final Integer amortizationTypeValue = amortizationType.getValue(); + + final TransactionProcessingStrategyCode processingStrategyCode = TransactionProcessingStrategyCode + .valueOf(transactionProcessingStrategyCode); + final String transactionProcessingStrategyCodeValue = processingStrategyCode.getValue(); + + Integer graceOnArrearsAgeingValue = Integer.valueOf(graceOnArrearsAgeingStr); + + final PostLoansRequest loansRequest = loanRequestFactory// + .defaultLoansRequest(clientId)// + .productId(loanProductId)// + .principal(new BigDecimal(principal))// + .interestRatePerPeriod(interestRate)// + .interestType(interestTypeValue)// + .interestCalculationPeriodType(interestCalculationPeriodValue)// + .amortizationType(amortizationTypeValue)// + .loanTermFrequency(loanTermFrequency)// + .loanTermFrequencyType(loanTermFrequencyTypeValue)// + .numberOfRepayments(numberOfRepayments)// + .repaymentEvery(repaymentFrequency)// + .repaymentFrequencyType(repaymentFrequencyTypeValue)// + .submittedOnDate(submitDate)// + .expectedDisbursementDate(submitDate)// + .graceOnPrincipalPayment(graceOnPrincipalPayment)// + .graceOnInterestPayment(graceOnInterestPayment)// + .graceOnInterestPayment(graceOnInterestCharged)// + .transactionProcessingStrategyCode(transactionProcessingStrategyCodeValue)// + .graceOnArrearsAgeing(graceOnArrearsAgeingValue);// + + final Response response = loansApi.calculateLoanScheduleOrSubmitLoanApplication(loansRequest, "").execute(); + testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + ErrorHelper.checkSuccessfulApiCall(response); + + eventCheckHelper.createLoanEventCheck(response); + } + public void createFullyCustomizedLoanWithCharges(final List loanData) throws IOException { final String loanProduct = loanData.get(0); final String submitDate = loanData.get(1); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature index a382d7e919b..e94a8994902 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelinquency.feature @@ -2144,3 +2144,35 @@ Feature: LoanDelinquency | 3 | RANGE_30 | 125.00 | | 4 | RANGE_60 | 375.00 | | 5 | RANGE_90 | 250.00 | + + @TestRailId:C4140 + Scenario: Verify that loan delinquent days are correct when graceOnArrearsAgeing is set on loan product level (value=3) + When Admin sets the business date to "01 January 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_BUYDOWN_FEES | 01 January 2025 | 1000 | 0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + And Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + And Admin sets the business date to "15 May 2025" + And Admin runs inline COB job for Loan + Then Admin checks that delinquency range is: "RANGE_90" and has delinquentDate "2025-02-04" + And Loan has the following LOAN level delinquency data: + | classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays | + | RANGE_90 | 666.68 | 04 February 2025 | 100 | 103 | + + @TestRailId:C4141 + Scenario: Verify that loan delinquent days are correct when graceOnArrearsAgeing is overrided on loan level (value=5) + When Admin sets the business date to "01 January 2025" + And Admin creates a client with random data + And Admin creates a fully customized loan with graceOnArrearsAgeing and following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | graceOnArrearsAgeing | + | LP2_PROGRESSIVE_ADVANCED_PAYMENT_ALLOCATION_BUYDOWN_FEES | 01 January 2025 | 1000 | 0 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | 5 | + And Admin successfully approves the loan on "01 January 2025" with "1000" amount and expected disbursement date on "01 January 2025" + And Admin successfully disburse the loan on "01 January 2025" with "1000" EUR transaction amount + And Admin sets the business date to "15 May 2025" + And Admin runs inline COB job for Loan + Then Admin checks that delinquency range is: "RANGE_90" and has delinquentDate "2025-02-06" + And Loan has the following LOAN level delinquency data: + | classification | delinquentAmount | delinquentDate | delinquentDays | pastDueDays | + | RANGE_90 | 666.68 | 06 February 2025 | 98 | 103 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature index b955d0f272c..7d8fc9aa93f 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanOverrideFileds.feature @@ -1,6 +1,7 @@ @LoanOverrideFields Feature: LoanOverrideFields + @TestRailId:C4142 Scenario: Verify that all nullable fields default to product when overrides not allowed and not provided When Admin sets the business date to the actual date When Admin creates a client with random data @@ -15,6 +16,7 @@ Feature: LoanOverrideFields Then LoanDetails has "graceOnInterestPayment" field with value: "1" Then LoanDetails has "graceOnArrearsAgeing" field with value: "3" + @TestRailId:C4143 Scenario: Verify that all nullable fields ignore overrides when overrides not allowed When Admin sets the business date to the actual date When Admin creates a client with random data @@ -29,6 +31,7 @@ Feature: LoanOverrideFields Then LoanDetails has "graceOnInterestPayment" field with value: "1" Then LoanDetails has "graceOnArrearsAgeing" field with value: "3" + @TestRailId:C4144 Scenario: Verify that nullable fields default to product when override is allowed but not provided When Admin sets the business date to the actual date When Admin creates a client with random data @@ -43,6 +46,7 @@ Feature: LoanOverrideFields Then LoanDetails has "graceOnInterestPayment" field with value: "1" Then LoanDetails has "graceOnArrearsAgeing" field with value: "3" + @TestRailId:C4145 Scenario: Verify that nullable fields default to product when override is allowed and provided When Admin sets the business date to the actual date When Admin creates a client with random data From 499f4dbe5da763aaa0d73c6a84c5a4b64cfe0186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20R=C3=B8sdal?= Date: Tue, 28 Oct 2025 12:57:45 +0100 Subject: [PATCH 36/36] FINERACT-2389: Upgrade OpenPDF to v3 --- .../main/groovy/org.apache.fineract.dependencies.gradle | 2 +- .../dataqueries/service/ReadReportingServiceImpl.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle b/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle index c4677a432dd..0341d6360a1 100644 --- a/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle +++ b/buildSrc/src/main/groovy/org.apache.fineract.dependencies.gradle @@ -64,7 +64,7 @@ dependencyManagement { exclude 'javax.activation:activation' } dependency 'commons-io:commons-io:2.18.0' - dependency 'com.github.librepdf:openpdf:2.0.3' + dependency 'com.github.librepdf:openpdf:3.0.0' dependency ('org.mnode.ical4j:ical4j:3.2.19') { exclude 'com.sun.mail:javax.mail' exclude 'org.codehaus.groovy:groovy' diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java index 8d7cf451104..2fd47216f2c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java @@ -18,10 +18,6 @@ */ package org.apache.fineract.infrastructure.dataqueries.service; -import com.lowagie.text.Document; -import com.lowagie.text.PageSize; -import com.lowagie.text.pdf.PdfPTable; -import com.lowagie.text.pdf.PdfWriter; import jakarta.ws.rs.core.StreamingOutput; import java.io.ByteArrayOutputStream; import java.io.File; @@ -61,6 +57,10 @@ import org.apache.fineract.infrastructure.security.service.SqlInjectionPreventerService; import org.apache.fineract.infrastructure.security.utils.LogParameterEscapeUtil; import org.apache.fineract.useradministration.domain.AppUser; +import org.openpdf.text.Document; +import org.openpdf.text.PageSize; +import org.openpdf.text.pdf.PdfPTable; +import org.openpdf.text.pdf.PdfWriter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.support.rowset.SqlRowSet;