This is an automated email from the ASF dual-hosted git repository.

adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git


The following commit(s) were added to refs/heads/develop by this push:
     new 633acf204b FINERACT-2421: Allow reage preview API via Batch API
633acf204b is described below

commit 633acf204be5e91df48a42ad0d250b00f11ce7ef
Author: Adam Saghy <[email protected]>
AuthorDate: Wed Jan 14 20:56:26 2026 +0100

    FINERACT-2421: Allow reage preview API via Batch API
---
 .../batch/command/CommandStrategyProvider.java     |  11 +-
 .../batch/command/CommandStrategyUtils.java        |  91 +++++-
 ...eagePreviewByLoanExternalIdCommandStrategy.java |  95 +++++++
 .../GetReagePreviewByLoanIdCommandStrategy.java    |  93 +++++++
 .../service/reaging/LoanReAgingService.java        |   3 +-
 .../batch/command/CommandStrategyProviderTest.java |  21 +-
 .../batch/command/CommandStrategyUtilsTest.java    | 305 +++++++++++++++++++++
 ...PreviewByLoanExternalIdCommandStrategyTest.java | 277 +++++++++++++++++++
 ...GetReagePreviewByLoanIdCommandStrategyTest.java | 273 ++++++++++++++++++
 9 files changed, 1163 insertions(+), 6 deletions(-)

diff --git 
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
 
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
index ccc7d5a107..e024b35df3 100644
--- 
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
+++ 
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java
@@ -47,12 +47,12 @@ public class CommandStrategyProvider {
     /**
      * Regex pattern for specifying any number of query params or not specific 
any query param
      */
-    private static final String OPTIONAL_QUERY_PARAM_REGEX = 
"(\\?(\\w+(?:\\=[\\w,]+|&)+)+)?";
+    private static final String OPTIONAL_QUERY_PARAM_REGEX = 
"(\\?(\\w+=[^&]+)(?:&\\w+=[^&]+)*)?";
 
     /**
      * Regex pattern for specifying query params
      */
-    private static final String MANDATORY_QUERY_PARAM_REGEX = 
"(\\?(\\w+(?:\\=[\\w\\-,]+|&)+)+)";
+    private static final String MANDATORY_QUERY_PARAM_REGEX = 
"(\\?(\\w+=[^&]+)(?:&\\w+=[^&]+)*)";
 
     /**
      * Regex pattern for specifying any query param that has key = 'command' 
or not specific anything.
@@ -201,6 +201,13 @@ public class CommandStrategyProvider {
                 CommandContext.resource("v1\\/loans\\/external-id\\/" + 
UUID_PARAM_REGEX + "\\/transactions\\/external-id\\/"
                         + UUID_PARAM_REGEX + 
OPTIONAL_QUERY_PARAM_REGEX).method(GET).build(),
                 "getLoanTransactionByExternalIdCommandStrategy");
+        commandStrategies.put(
+                CommandContext.resource("v1\\/loans\\/" + NUMBER_REGEX + 
"\\/transactions\\/reage-preview" + OPTIONAL_QUERY_PARAM_REGEX)
+                        .method(GET).build(),
+                "getReagePreviewByLoanIdCommandStrategy");
+        commandStrategies.put(CommandContext
+                .resource("v1\\/loans\\/external-id\\/" + UUID_PARAM_REGEX + 
"\\/transactions\\/reage-preview" + OPTIONAL_QUERY_PARAM_REGEX)
+                .method(GET).build(), 
"getReagePreviewByLoanExternalIdCommandStrategy");
         commandStrategies.put(CommandContext.resource("v1\\/datatables\\/" + 
ALPHANUMBERIC_WITH_UNDERSCORE_REGEX + "\\/" + NUMBER_REGEX)
                 .method(POST).build(), "createDatatableEntryCommandStrategy");
         commandStrategies.put(CommandContext
diff --git 
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
 
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
index 56f2db01cf..71d7813857 100644
--- 
a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
+++ 
b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyUtils.java
@@ -18,6 +18,11 @@
  */
 package org.apache.fineract.batch.command;
 
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.regex.Matcher;
@@ -39,7 +44,7 @@ public final class CommandStrategyUtils {
      *
      * @param relativeUrl
      *            the relative URL
-     * @return the query parameters in a map
+     * @return the query parameters in a map with URL-decoded values
      */
     public static Map<String, String> getQueryParameters(final String 
relativeUrl) {
         final String queryParameterStr = 
StringUtils.substringAfter(relativeUrl, "?");
@@ -47,7 +52,14 @@ public final class CommandStrategyUtils {
         final Map<String, String> queryParametersMap = new HashMap<>();
         for (String parameterStr : queryParametersArray) {
             String[] keyValue = StringUtils.split(parameterStr, "=");
-            queryParametersMap.put(keyValue[0], keyValue[1]);
+            String key = URLDecoder.decode(keyValue[0], 
StandardCharsets.UTF_8);
+            String value = "";
+            if (keyValue.length > 1 && StringUtils.isNotEmpty(keyValue[1])) {
+                value = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
+            } else if (keyValue.length > 1) {
+                value = keyValue[1];
+            }
+            queryParametersMap.put(key, value);
         }
         return queryParametersMap;
     }
@@ -86,4 +98,79 @@ public final class CommandStrategyUtils {
         return m.matches();
     }
 
+    /**
+     * Builds a request object from query parameters using reflection and the 
builder pattern. This method automatically
+     * detects field types and converts string values to the appropriate type 
(Integer, String, etc.).
+     *
+     * @param queryParameters
+     *            map of query parameter names to their string values
+     * @param requestClass
+     *            the class of the request object to build (must have a 
builder() method)
+     * @param <T>
+     *            the type of the request object
+     * @return the built request object with fields populated from query 
parameters
+     * @throws RuntimeException
+     *             if the request class doesn't have a builder or if 
reflection fails
+     */
+    public static <T> T buildRequestFromQueryParameters(final Map<String, 
String> queryParameters, final Class<T> requestClass) {
+        try {
+            // Get the builder
+            Method builderMethod = requestClass.getMethod("builder");
+            Object builder = builderMethod.invoke(null);
+            Class<?> builderClass = builder.getClass();
+
+            // Iterate through all declared fields of the request class
+            for (Field field : requestClass.getDeclaredFields()) {
+                String fieldName = field.getName();
+                String paramValue = queryParameters.get(fieldName);
+
+                // Skip if parameter is not present or is the serialVersionUID 
field
+                if (paramValue == null || 
"serialVersionUID".equals(fieldName)) {
+                    continue;
+                }
+
+                // Find the builder method for this field
+                Method builderSetter = builderClass.getMethod(fieldName, 
field.getType());
+
+                // Convert the string value to the appropriate type and invoke 
builder method
+                Object convertedValue = convertValue(paramValue, 
field.getType());
+                builderSetter.invoke(builder, convertedValue);
+            }
+
+            // Call build() method to create the final object
+            Method buildMethod = builderClass.getMethod("build");
+            return requestClass.cast(buildMethod.invoke(builder));
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to build request object from 
query parameters", e);
+        }
+    }
+
+    /**
+     * Converts a string value to the target type.
+     *
+     * @param value
+     *            the string value to convert
+     * @param targetType
+     *            the target type
+     * @return the converted value
+     */
+    private static Object convertValue(final String value, final Class<?> 
targetType) {
+        if (targetType == String.class) {
+            return value;
+        } else if (targetType == Integer.class || targetType == int.class) {
+            return Integer.parseInt(value);
+        } else if (targetType == Long.class || targetType == long.class) {
+            return Long.parseLong(value);
+        } else if (targetType == BigDecimal.class) {
+            return new BigDecimal(value);
+        } else if (targetType == Double.class || targetType == double.class) {
+            return Double.parseDouble(value);
+        } else if (targetType == Boolean.class || targetType == boolean.class) 
{
+            return Boolean.parseBoolean(value);
+        } else {
+            // Default to string for unknown types
+            return value;
+        }
+    }
+
 }
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.java
 
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.java
new file mode 100644
index 0000000000..eddb703f79
--- /dev/null
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategy.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.batch.command.internal;
+
+import static 
org.apache.fineract.batch.command.CommandStrategyUtils.relativeUrlWithoutVersion;
+
+import com.google.common.base.Splitter;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.batch.command.CommandStrategy;
+import org.apache.fineract.batch.command.CommandStrategyUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import 
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import 
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import 
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import 
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+/**
+ * Implements {@link CommandStrategy} to calculate the reage preview for the 
given term for a particular loan by its
+ * loan external id. It passes the contents of the body from the BatchRequest 
to {@link LoanTransactionsApiResource} and
+ * gets back the response. This class will also catch any errors raised by 
{@link LoanTransactionsApiResource} and map
+ * those errors to appropriate status codes in BatchResponse.
+ *
+ * @see CommandStrategy
+ * @see BatchRequest
+ * @see BatchResponse
+ */
+@Component
+@RequiredArgsConstructor
+public class GetReagePreviewByLoanExternalIdCommandStrategy implements 
CommandStrategy {
+
+    /**
+     * Loan transactions api resource {@link LoanTransactionsApiResource}.
+     */
+    private final LoanTransactionsApiResource loanTransactionsApiResource;
+
+    /**
+     * The toApiJsonSerializer to convert json to object
+     */
+    private final DefaultToApiJsonSerializer<LoanScheduleData> 
toApiJsonSerializer;
+
+    @Override
+    public BatchResponse execute(final BatchRequest request, final UriInfo 
uriInfo) {
+        final BatchResponse response = new BatchResponse();
+
+        response.setRequestId(request.getRequestId());
+        response.setHeaders(request.getHeaders());
+
+        final String relativeUrl = relativeUrlWithoutVersion(request);
+
+        // Expected pattern - loans/external-id/" + UUID_PARAM_REGEX +
+        // 
"/transactions/reage-preview?queryParam1=<blah>&queryParam2=<blah>&....
+        // Get the loan external id
+        final List<String> pathParameters = 
Splitter.on('/').splitToList(relativeUrl);
+        final String loanExternalId = pathParameters.get(2);
+        Map<String, String> queryParameters = new HashMap<>();
+        if (relativeUrl.indexOf('?') > 0) {
+            queryParameters = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+        }
+
+        // Build ReAgePreviewRequest from query parameters using generic 
utility
+        final ReAgePreviewRequest reAgePreviewRequest = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParameters,
+                ReAgePreviewRequest.class);
+
+        // Calls 'previewReAgeSchedule' function from 
'loanTransactionsApiResource' using external id
+        response.setStatusCode(HttpStatus.SC_OK);
+        // Sets the body of the response after getting reage preview
+        response.setBody(
+                
toApiJsonSerializer.serialize(loanTransactionsApiResource.previewReAgeSchedule(loanExternalId,
 reAgePreviewRequest)));
+
+        return response;
+    }
+}
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.java
 
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.java
new file mode 100644
index 0000000000..01d1f7b344
--- /dev/null
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategy.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.batch.command.internal;
+
+import static 
org.apache.fineract.batch.command.CommandStrategyUtils.relativeUrlWithoutVersion;
+
+import com.google.common.base.Splitter;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.batch.command.CommandStrategy;
+import org.apache.fineract.batch.command.CommandStrategyUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import 
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import 
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import 
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import 
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+/**
+ * Implements {@link CommandStrategy} to calculate the reage preview for the 
given term for a particular loan by its
+ * loan id. It passes the contents of the body from the BatchRequest to {@link 
LoanTransactionsApiResource} and gets
+ * back the response. This class will also catch any errors raised by {@link 
LoanTransactionsApiResource} and map those
+ * errors to appropriate status codes in BatchResponse.
+ *
+ * @see CommandStrategy
+ * @see BatchRequest
+ * @see BatchResponse
+ */
+@Component
+@RequiredArgsConstructor
+public class GetReagePreviewByLoanIdCommandStrategy implements CommandStrategy 
{
+
+    /**
+     * Loan transactions api resource {@link LoanTransactionsApiResource}.
+     */
+    private final LoanTransactionsApiResource loanTransactionsApiResource;
+
+    /**
+     * The toApiJsonSerializer to convert json to object
+     */
+    private final DefaultToApiJsonSerializer<LoanScheduleData> 
toApiJsonSerializer;
+
+    @Override
+    public BatchResponse execute(final BatchRequest request, final UriInfo 
uriInfo) {
+        final BatchResponse response = new BatchResponse();
+
+        response.setRequestId(request.getRequestId());
+        response.setHeaders(request.getHeaders());
+
+        final String relativeUrl = relativeUrlWithoutVersion(request);
+
+        // Expected pattern - loans/" + NUMBER + 
"/transactions/reage-preview?queryParam1=<blah>&queryParam2=<blah>&....
+        // Get the loan external id
+        final List<String> pathParameters = 
Splitter.on('/').splitToList(relativeUrl);
+        final Long loanId = Long.parseLong(pathParameters.get(1));
+        Map<String, String> queryParameters = new HashMap<>();
+        if (relativeUrl.indexOf('?') > 0) {
+            queryParameters = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+        }
+
+        // Build ReAgePreviewRequest from query parameters using generic 
utility
+        final ReAgePreviewRequest reAgePreviewRequest = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParameters,
+                ReAgePreviewRequest.class);
+
+        // Calls 'previewReAgeSchedule' function from 
'loanTransactionsApiResource' using external id
+        response.setStatusCode(HttpStatus.SC_OK);
+        // Sets the body of the response after getting reage preview
+        
response.setBody(toApiJsonSerializer.serialize(loanTransactionsApiResource.previewReAgeSchedule(loanId,
 reAgePreviewRequest)));
+
+        return response;
+    }
+}
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
index 84c1d22a46..9ad702ea0d 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java
@@ -77,6 +77,7 @@ import 
org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactio
 import org.apache.fineract.portfolio.note.domain.Note;
 import org.apache.fineract.portfolio.note.domain.NoteRepository;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
 @Service
@@ -129,7 +130,7 @@ public class LoanReAgingService {
                 .with(changes).build();
     }
 
-    @Transactional(readOnly = true)
+    @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
     public LoanScheduleData previewReAge(final Long loanId, final String 
loanExternalId, final ReAgePreviewRequest reAgePreviewRequest) {
         final Loan loan = loanId != null ? loanAssembler.assembleFrom(loanId)
                 : 
loanAssembler.assembleFrom(ExternalIdFactory.produce(loanExternalId), false);
diff --git 
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
index c78b72d4fa..9333ebddf2 100644
--- 
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
+++ 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java
@@ -52,6 +52,8 @@ import 
org.apache.fineract.batch.command.internal.GetLoanByExternalIdCommandStra
 import org.apache.fineract.batch.command.internal.GetLoanByIdCommandStrategy;
 import 
org.apache.fineract.batch.command.internal.GetLoanTransactionByExternalIdCommandStrategy;
 import 
org.apache.fineract.batch.command.internal.GetLoanTransactionByIdCommandStrategy;
+import 
org.apache.fineract.batch.command.internal.GetReagePreviewByLoanExternalIdCommandStrategy;
+import 
org.apache.fineract.batch.command.internal.GetReagePreviewByLoanIdCommandStrategy;
 import 
org.apache.fineract.batch.command.internal.LoanStateTransistionsByExternalIdCommandStrategy;
 import 
org.apache.fineract.batch.command.internal.ModifyLoanApplicationCommandStrategy;
 import org.apache.fineract.batch.command.internal.UnknownCommandStrategy;
@@ -206,7 +208,24 @@ public class CommandStrategyProviderTest {
                 
Arguments.of("loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/interest-pauses",
 HttpMethod.POST,
                         "createLoanInterestPauseByExternalIdCommandStrategy", 
mock(CommandStrategy.class)),
                 
Arguments.of("loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/interest-pauses/123",
 HttpMethod.PUT,
-                        "updateLoanInterestPauseByExternalIdCommandStrategy", 
mock(CommandStrategy.class)));
+                        "updateLoanInterestPauseByExternalIdCommandStrategy", 
mock(CommandStrategy.class)),
+                Arguments.of("loans/123/transactions/reage-preview", 
HttpMethod.GET, "getReagePreviewByLoanIdCommandStrategy",
+                        mock(GetReagePreviewByLoanIdCommandStrategy.class)),
+                
Arguments.of("loans/123/transactions/reage-preview?frequencyNumber=1&frequencyType=MONTHS",
 HttpMethod.GET,
+                        "getReagePreviewByLoanIdCommandStrategy", 
mock(GetReagePreviewByLoanIdCommandStrategy.class)),
+                Arguments.of(
+                        
"loans/123/transactions/reage-preview?frequencyType=MONTHS&locale=en_US&frequencyNumber=1&dateFormat=MM%2Fdd%2Fyyyy&startDate=02%2F05%2F2026&numberOfInstallments=6",
+                        HttpMethod.GET, 
"getReagePreviewByLoanIdCommandStrategy", 
mock(GetReagePreviewByLoanIdCommandStrategy.class)),
+                
Arguments.of("loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/transactions/reage-preview",
 HttpMethod.GET,
+                        "getReagePreviewByLoanExternalIdCommandStrategy", 
mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)),
+                Arguments.of(
+                        
"loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/transactions/reage-preview?frequencyNumber=2&frequencyType=WEEKS",
+                        HttpMethod.GET, 
"getReagePreviewByLoanExternalIdCommandStrategy",
+                        
mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)),
+                Arguments.of(
+                        
"loans/external-id/0083477d-ea2a-45a4-a244-cb79a9ecf741/transactions/reage-preview?frequencyType=MONTHS&locale=en_US&frequencyNumber=1&dateFormat=MM%2Fdd%2Fyyyy&startDate=02%2F05%2F2026&numberOfInstallments=6",
+                        HttpMethod.GET, 
"getReagePreviewByLoanExternalIdCommandStrategy",
+                        
mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)));
     }
 
     /**
diff --git 
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
index 6f1394e240..b15fbf35e4 100644
--- 
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
+++ 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyUtilsTest.java
@@ -19,9 +19,21 @@
 package org.apache.fineract.batch.command;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
 import org.apache.fineract.batch.domain.BatchRequest;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 class CommandStrategyUtilsTest {
 
@@ -90,4 +102,297 @@ class CommandStrategyUtilsTest {
         // then
         
assertThat(result).isEqualTo("clients/123?command=action&something=else");
     }
+
+    // Tests for buildRequestFromQueryParameters method
+
+    private static Stream<Arguments> typeConversionTestCases() {
+        return Stream.of(Arguments.of("stringField", "testValue", (Object) 
"testValue"), Arguments.of("integerField", "42", 42),
+                Arguments.of("longField", "9876543210", 9876543210L), 
Arguments.of("bigDecimalField", "123.456", new BigDecimal("123.456")),
+                Arguments.of("bigDecimalField", 
"0.1000000000000000055511151231257827021181583404541015625",
+                        new 
BigDecimal("0.1000000000000000055511151231257827021181583404541015625")),
+                Arguments.of("doubleField", "3.14", 3.14), 
Arguments.of("booleanField", "true", true),
+                Arguments.of("booleanField", "false", false), 
Arguments.of("booleanField", "TRUE", true));
+    }
+
+    @ParameterizedTest
+    @MethodSource("typeConversionTestCases")
+    public void testBuildRequestFromQueryParametersTypeConversion(String 
fieldName, String value, Object expectedValue) {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put(fieldName, value);
+
+        // when
+        TestRequest result = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class);
+
+        // then
+        assertThat(result).isNotNull();
+        Object actualValue = getFieldValue(result, fieldName);
+        if (expectedValue instanceof BigDecimal) {
+            assertThat((BigDecimal) 
actualValue).isEqualByComparingTo((BigDecimal) expectedValue);
+        } else {
+            assertThat(actualValue).isEqualTo(expectedValue);
+        }
+    }
+
+    private static Stream<Arguments> invalidTypeConversionTestCases() {
+        return Stream.of(Arguments.of("integerField", "notANumber"), 
Arguments.of("longField", "notANumber"),
+                Arguments.of("bigDecimalField", "notANumber"), 
Arguments.of("doubleField", "notANumber"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("invalidTypeConversionTestCases")
+    public void 
testBuildRequestFromQueryParametersInvalidTypeConversion(String fieldName, 
String invalidValue) {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put(fieldName, invalidValue);
+
+        // when/then
+        assertThatThrownBy(() -> 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class))
+                
.isInstanceOf(RuntimeException.class).hasMessageContaining("Failed to build 
request object from query parameters")
+                .hasCauseInstanceOf(NumberFormatException.class);
+    }
+
+    @Test
+    public void testBuildRequestFromQueryParametersWithAllFieldTypes() {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put("stringField", "testValue");
+        queryParams.put("integerField", "42");
+        queryParams.put("longField", "9876543210");
+        queryParams.put("bigDecimalField", "3.14");
+        queryParams.put("doubleField", "2.71");
+        queryParams.put("booleanField", "true");
+
+        // when
+        TestRequest result = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result.getStringField()).isEqualTo("testValue");
+        assertThat(result.getIntegerField()).isEqualTo(42);
+        assertThat(result.getLongField()).isEqualTo(9876543210L);
+        assertThat(result.getBigDecimalField()).isEqualByComparingTo(new 
BigDecimal("3.14"));
+        assertThat(result.getDoubleField()).isEqualTo(2.71);
+        assertThat(result.getBooleanField()).isTrue();
+    }
+
+    @Test
+    public void testBuildRequestFromQueryParametersWithMissingFields() {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put("stringField", "testValue");
+
+        // when
+        TestRequest result = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result.getStringField()).isEqualTo("testValue");
+        assertThat(result.getIntegerField()).isNull();
+        assertThat(result.getLongField()).isNull();
+        assertThat(result.getBigDecimalField()).isNull();
+        assertThat(result.getDoubleField()).isNull();
+        assertThat(result.getBooleanField()).isNull();
+    }
+
+    @Test
+    public void testBuildRequestFromQueryParametersWithEmptyMap() {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+
+        // when
+        TestRequest result = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result.getStringField()).isNull();
+        assertThat(result.getIntegerField()).isNull();
+        assertThat(result.getLongField()).isNull();
+        assertThat(result.getBigDecimalField()).isNull();
+        assertThat(result.getDoubleField()).isNull();
+        assertThat(result.getBooleanField()).isNull();
+    }
+
+    @Test
+    public void testBuildRequestFromQueryParametersSkipsSerialVersionUID() {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put("serialVersionUID", "12345");
+        queryParams.put("stringField", "testValue");
+
+        // when
+        TestRequest result = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result.getStringField()).isEqualTo("testValue");
+    }
+
+    @Test
+    public void testBuildRequestFromQueryParametersWithClassWithoutBuilder() {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put("field", "value");
+
+        // when/then
+        assertThatThrownBy(() -> 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
ClassWithoutBuilder.class))
+                
.isInstanceOf(RuntimeException.class).hasMessageContaining("Failed to build 
request object from query parameters");
+    }
+
+    private Object getFieldValue(TestRequest request, String fieldName) {
+        switch (fieldName) {
+            case "stringField":
+                return request.getStringField();
+            case "integerField":
+                return request.getIntegerField();
+            case "longField":
+                return request.getLongField();
+            case "bigDecimalField":
+                return request.getBigDecimalField();
+            case "doubleField":
+                return request.getDoubleField();
+            case "booleanField":
+                return request.getBooleanField();
+            default:
+                return null;
+        }
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    static class TestRequest {
+
+        private String stringField;
+        private Integer integerField;
+        private Long longField;
+        private BigDecimal bigDecimalField;
+        private Double doubleField;
+        private Boolean booleanField;
+    }
+
+    static class ClassWithoutBuilder {
+
+        private String field;
+
+        ClassWithoutBuilder(String field) {
+            this.field = field;
+        }
+    }
+
+    // Tests for getQueryParameters method with URL decoding
+
+    private static Stream<Arguments> urlDecodingTestCases() {
+        return Stream.of(
+                // URL-encoded forward slashes
+                Arguments.of("loans/123?dateFormat=MM%2Fdd%2Fyyyy", 
"dateFormat", "MM/dd/yyyy"),
+                Arguments.of("loans/123?startDate=02%2F05%2F2026", 
"startDate", "02/05/2026"),
+                // URL-encoded spaces
+                Arguments.of("loans/123?name=John%20Doe", "name", "John Doe"),
+                // URL-encoded special characters
+                Arguments.of("loans/123?email=user%40example.com", "email", 
"[email protected]"),
+                Arguments.of("loans/123?query=hello%26world", "query", 
"hello&world"),
+                // URL-encoded percentage sign
+                Arguments.of("loans/123?discount=50%25", "discount", "50%"),
+                // Non-encoded values (should remain unchanged)
+                Arguments.of("loans/123?locale=en_US", "locale", "en_US"),
+                Arguments.of("loans/123?frequencyType=MONTHS", 
"frequencyType", "MONTHS"),
+                Arguments.of("loans/123?frequencyNumber=1", "frequencyNumber", 
"1"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("urlDecodingTestCases")
+    public void testGetQueryParametersWithUrlDecoding(String relativeUrl, 
String expectedKey, String expectedValue) {
+        // when
+        Map<String, String> result = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result).containsKey(expectedKey);
+        assertThat(result.get(expectedKey)).isEqualTo(expectedValue);
+    }
+
+    @Test
+    public void testGetQueryParametersWithMultipleUrlEncodedParams() {
+        // given
+        String relativeUrl = 
"loans/123?dateFormat=MM%2Fdd%2Fyyyy&startDate=02%2F05%2F2026&locale=en_US&name=John%20Doe";
+
+        // when
+        Map<String, String> result = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result).hasSize(4);
+        assertThat(result.get("dateFormat")).isEqualTo("MM/dd/yyyy");
+        assertThat(result.get("startDate")).isEqualTo("02/05/2026");
+        assertThat(result.get("locale")).isEqualTo("en_US");
+        assertThat(result.get("name")).isEqualTo("John Doe");
+    }
+
+    @Test
+    public void testGetQueryParametersWithComplexUrlEncodedQuery() {
+        // given - using the actual sample URL from the requirement
+        String relativeUrl = 
"loans/external-id/0083477d-ea2a-45a4-a244-cb79a9ecf741/transactions/reage-preview"
+                + 
"?frequencyType=MONTHS&locale=en_US&frequencyNumber=1&dateFormat=MM%2Fdd%2Fyyyy"
+                + "&startDate=02%2F05%2F2026&numberOfInstallments=6";
+
+        // when
+        Map<String, String> result = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result).hasSize(6);
+        assertThat(result.get("frequencyType")).isEqualTo("MONTHS");
+        assertThat(result.get("locale")).isEqualTo("en_US");
+        assertThat(result.get("frequencyNumber")).isEqualTo("1");
+        assertThat(result.get("dateFormat")).isEqualTo("MM/dd/yyyy");
+        assertThat(result.get("startDate")).isEqualTo("02/05/2026");
+        assertThat(result.get("numberOfInstallments")).isEqualTo("6");
+    }
+
+    @Test
+    public void testGetQueryParametersWithEmptyValue() {
+        // given
+        String relativeUrl = "loans/123?param1=value1&param2=&param3=value3";
+
+        // when
+        Map<String, String> result = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result).hasSize(3);
+        assertThat(result.get("param1")).isEqualTo("value1");
+        assertThat(result.get("param2")).isEqualTo("");
+        assertThat(result.get("param3")).isEqualTo("value3");
+    }
+
+    @Test
+    public void testGetQueryParametersWithNoQueryParams() {
+        // given
+        String relativeUrl = "loans/123";
+
+        // when
+        Map<String, String> result = 
CommandStrategyUtils.getQueryParameters(relativeUrl);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    public void testBuildRequestFromQueryParametersWithUrlEncodedValues() {
+        // given
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put("stringField", "Hello World"); // Already decoded by 
getQueryParameters
+        queryParams.put("integerField", "42");
+
+        // when
+        TestRequest result = 
CommandStrategyUtils.buildRequestFromQueryParameters(queryParams, 
TestRequest.class);
+
+        // then
+        assertThat(result).isNotNull();
+        assertThat(result.getStringField()).isEqualTo("Hello World");
+        assertThat(result.getIntegerField()).isEqualTo(42);
+    }
 }
diff --git 
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java
 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java
new file mode 100644
index 0000000000..0b601d520c
--- /dev/null
+++ 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanExternalIdCommandStrategyTest.java
@@ -0,0 +1,277 @@
+/**
+ * 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.batch.command.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import 
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import 
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import 
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import 
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Test class for {@link GetReagePreviewByLoanExternalIdCommandStrategy}.
+ */
+public class GetReagePreviewByLoanExternalIdCommandStrategyTest {
+
+    /**
+     * The query parameter provider.
+     *
+     * @return Arguments.
+     */
+    private static Stream<Arguments> provideQueryParameters() {
+        return Stream.of(Arguments.of(1, "MONTHS", "2024-01-15", 12, "dd MMMM 
yyyy", "en"),
+                Arguments.of(2, "WEEKS", "2024-02-01", 24, "yyyy-MM-dd", 
"en_US"),
+                Arguments.of(3, "DAYS", "2024-03-10", 6, "dd/MM/yyyy", 
"en_GB"));
+    }
+
+    /**
+     * Test {@link GetReagePreviewByLoanExternalIdCommandStrategy#execute} 
with wrong parameter names.
+     */
+    @Test
+    public void testExecuteWithWrongParameterNames() {
+        // given
+        final TestContext testContext = new TestContext();
+
+        final String loanExternalId = UUID.randomUUID().toString();
+        // Build request with WRONG parameter names
+        final BatchRequest request = 
getBatchRequestWithWrongParameterNames(loanExternalId);
+
+        // Mock LoanScheduleData since it doesn't have a default constructor
+        final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+        final String responseBody = "{\"periods\":[]}";
+
+        
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanExternalId),
 any(ReAgePreviewRequest.class)))
+                .willReturn(loanScheduleData);
+        
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+        // when
+        final BatchResponse response = 
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+        // then
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+
+        // Verify the API resource was called
+        
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanExternalId),
+                testContext.reAgePreviewRequestCaptor.capture());
+
+        // Verify the serializer was invoked
+        
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+        // Verify that wrong parameter names result in null values
+        final ReAgePreviewRequest capturedRequest = 
testContext.reAgePreviewRequestCaptor.getValue();
+        assertThat(capturedRequest.getFrequencyNumber()).isNull(); // 
frequencyNo was sent, not frequencyNumber
+        assertThat(capturedRequest.getFrequencyType()).isNull(); // freqType 
was sent, not frequencyType
+        assertThat(capturedRequest.getStartDate()).isNull(); // start was 
sent, not startDate
+        assertThat(capturedRequest.getNumberOfInstallments()).isNull(); // 
installments was sent, not
+                                                                        // 
numberOfInstallments
+        assertThat(capturedRequest.getDateFormat()).isNull(); // format was 
sent, not dateFormat
+        assertThat(capturedRequest.getLocale()).isNull(); // lang was sent, 
not locale
+    }
+
+    /**
+     * Test {@link GetReagePreviewByLoanExternalIdCommandStrategy#execute} 
happy path scenario.
+     */
+    @ParameterizedTest
+    @MethodSource("provideQueryParameters")
+    public void testExecuteSuccessScenario(final Integer frequencyNumber, 
final String frequencyType, final String startDate,
+            final Integer numberOfInstallments, final String dateFormat, final 
String locale) {
+        // given
+        final TestContext testContext = new TestContext();
+
+        final String loanExternalId = UUID.randomUUID().toString();
+        final BatchRequest request = getBatchRequest(loanExternalId, 
frequencyNumber, frequencyType, startDate, numberOfInstallments,
+                dateFormat, locale);
+
+        // Mock LoanScheduleData since it doesn't have a default constructor
+        final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+        final String responseBody = "{\"periods\":[]}";
+
+        
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanExternalId),
 any(ReAgePreviewRequest.class)))
+                .willReturn(loanScheduleData);
+        
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+        // when
+        final BatchResponse response = 
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+        // then
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+        assertThat(response.getRequestId()).isEqualTo(request.getRequestId());
+        assertThat(response.getHeaders()).isEqualTo(request.getHeaders());
+        assertThat(response.getBody()).isEqualTo(responseBody);
+
+        // Verify the API resource was called with correct parameters
+        
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanExternalId),
+                testContext.reAgePreviewRequestCaptor.capture());
+
+        // Verify the serializer was invoked with the returned LoanScheduleData
+        
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+        // Verify the ReAgePreviewRequest was built correctly from query 
parameters
+        final ReAgePreviewRequest capturedRequest = 
testContext.reAgePreviewRequestCaptor.getValue();
+        
assertThat(capturedRequest.getFrequencyNumber()).isEqualTo(frequencyNumber);
+        
assertThat(capturedRequest.getFrequencyType()).isEqualTo(frequencyType);
+        assertThat(capturedRequest.getStartDate()).isEqualTo(startDate);
+        
assertThat(capturedRequest.getNumberOfInstallments()).isEqualTo(numberOfInstallments);
+        assertThat(capturedRequest.getDateFormat()).isEqualTo(dateFormat);
+        assertThat(capturedRequest.getLocale()).isEqualTo(locale);
+    }
+
+    /**
+     * Creates and returns a batch request with the given parameters.
+     *
+     * @param loanExternalId
+     *            the loan external id
+     * @param frequencyNumber
+     *            the frequency number
+     * @param frequencyType
+     *            the frequency type
+     * @param startDate
+     *            the start date
+     * @param numberOfInstallments
+     *            the number of installments
+     * @param dateFormat
+     *            the date format
+     * @param locale
+     *            the locale
+     * @return BatchRequest
+     */
+    private BatchRequest getBatchRequest(final String loanExternalId, final 
Integer frequencyNumber, final String frequencyType,
+            final String startDate, final Integer numberOfInstallments, final 
String dateFormat, final String locale) {
+
+        final BatchRequest br = new BatchRequest();
+        String relativeUrl = "loans/external-id/" + loanExternalId + 
"/transactions/reage-preview";
+
+        Set<String> queryParams = new HashSet<>();
+        queryParams.add("frequencyNumber=" + frequencyNumber);
+        queryParams.add("frequencyType=" + frequencyType);
+        queryParams.add("startDate=" + startDate);
+        queryParams.add("numberOfInstallments=" + numberOfInstallments);
+        queryParams.add("dateFormat=" + dateFormat);
+        queryParams.add("locale=" + locale);
+
+        relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+        br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setRelativeUrl(relativeUrl);
+        br.setMethod(HttpMethod.GET);
+        br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setBody("{}");
+
+        return br;
+    }
+
+    /**
+     * Creates and returns a batch request with WRONG parameter names to test 
validation.
+     *
+     * @param loanExternalId
+     *            the loan external id
+     * @return BatchRequest
+     */
+    private BatchRequest getBatchRequestWithWrongParameterNames(final String 
loanExternalId) {
+
+        final BatchRequest br = new BatchRequest();
+        String relativeUrl = "loans/external-id/" + loanExternalId + 
"/transactions/reage-preview";
+
+        Set<String> queryParams = new HashSet<>();
+        // Using wrong parameter names
+        queryParams.add("frequencyNo=1"); // should be frequencyNumber
+        queryParams.add("freqType=MONTHS"); // should be frequencyType
+        queryParams.add("start=2024-01-15"); // should be startDate
+        queryParams.add("installments=12"); // should be numberOfInstallments
+        queryParams.add("format=dd MMMM yyyy"); // should be dateFormat
+        queryParams.add("lang=en"); // should be locale
+
+        relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+        br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setRelativeUrl(relativeUrl);
+        br.setMethod(HttpMethod.GET);
+        br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setBody("{}");
+
+        return br;
+    }
+
+    /**
+     * Private test context class used since testng runs in parallel to avoid 
state between tests
+     */
+    private static class TestContext {
+
+        /**
+         * The Mock UriInfo
+         */
+        @Mock
+        private UriInfo uriInfo;
+
+        /**
+         * The Mock {@link LoanTransactionsApiResource}
+         */
+        @Mock
+        private LoanTransactionsApiResource loanTransactionsApiResource;
+
+        /**
+         * The Mock {@link DefaultToApiJsonSerializer}
+         */
+        @Mock
+        private DefaultToApiJsonSerializer<LoanScheduleData> 
toApiJsonSerializer;
+
+        /**
+         * The Captor for ReAgePreviewRequest
+         */
+        @Captor
+        private ArgumentCaptor<ReAgePreviewRequest> reAgePreviewRequestCaptor;
+
+        /**
+         * The class under test.
+         */
+        private final GetReagePreviewByLoanExternalIdCommandStrategy 
subjectToTest;
+
+        /**
+         * Constructor.
+         */
+        TestContext() {
+            MockitoAnnotations.openMocks(this);
+            subjectToTest = new 
GetReagePreviewByLoanExternalIdCommandStrategy(loanTransactionsApiResource, 
toApiJsonSerializer);
+        }
+    }
+}
diff --git 
a/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java
 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java
new file mode 100644
index 0000000000..5d2e1c2ab9
--- /dev/null
+++ 
b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/GetReagePreviewByLoanIdCommandStrategyTest.java
@@ -0,0 +1,273 @@
+/**
+ * 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.batch.command.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.core.UriInfo;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.fineract.batch.domain.BatchRequest;
+import org.apache.fineract.batch.domain.BatchResponse;
+import 
org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import 
org.apache.fineract.portfolio.loanaccount.api.LoanTransactionsApiResource;
+import 
org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
+import 
org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
+import org.apache.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Test class for {@link GetReagePreviewByLoanIdCommandStrategy}.
+ */
+public class GetReagePreviewByLoanIdCommandStrategyTest {
+
+    /**
+     * The query parameter provider.
+     *
+     * @return Arguments.
+     */
+    private static Stream<Arguments> provideQueryParameters() {
+        return Stream.of(Arguments.of(1L, 1, "MONTHS", "2024-01-15", 12, "dd 
MMMM yyyy", "en"),
+                Arguments.of(2L, 2, "WEEKS", "2024-02-01", 24, "yyyy-MM-dd", 
"en_US"),
+                Arguments.of(3L, 3, "DAYS", "2024-03-10", 6, "dd/MM/yyyy", 
"en_GB"));
+    }
+
+    /**
+     * Test {@link GetReagePreviewByLoanIdCommandStrategy#execute} with wrong 
parameter names.
+     */
+    @Test
+    public void testExecuteWithWrongParameterNames() {
+        // given
+        final TestContext testContext = new TestContext();
+
+        final Long loanId = 1L;
+        // Build request with WRONG parameter names
+        final BatchRequest request = 
getBatchRequestWithWrongParameterNames(loanId);
+
+        // Mock LoanScheduleData since it doesn't have a default constructor
+        final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+        final String responseBody = "{\"periods\":[]}";
+
+        
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanId), 
any(ReAgePreviewRequest.class)))
+                .willReturn(loanScheduleData);
+        
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+        // when
+        final BatchResponse response = 
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+        // then
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+
+        // Verify the API resource was called
+        
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanId),
 testContext.reAgePreviewRequestCaptor.capture());
+
+        // Verify the serializer was invoked
+        
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+        // Verify that wrong parameter names result in null values
+        final ReAgePreviewRequest capturedRequest = 
testContext.reAgePreviewRequestCaptor.getValue();
+        assertThat(capturedRequest.getFrequencyNumber()).isNull(); // 
frequencyNo was sent, not frequencyNumber
+        assertThat(capturedRequest.getFrequencyType()).isNull(); // freqType 
was sent, not frequencyType
+        assertThat(capturedRequest.getStartDate()).isNull(); // start was 
sent, not startDate
+        assertThat(capturedRequest.getNumberOfInstallments()).isNull(); // 
installments was sent, not
+                                                                        // 
numberOfInstallments
+        assertThat(capturedRequest.getDateFormat()).isNull(); // format was 
sent, not dateFormat
+        assertThat(capturedRequest.getLocale()).isNull(); // lang was sent, 
not locale
+    }
+
+    /**
+     * Test {@link GetReagePreviewByLoanIdCommandStrategy#execute} happy path 
scenario.
+     */
+    @ParameterizedTest
+    @MethodSource("provideQueryParameters")
+    public void testExecuteSuccessScenario(final Long loanId, final Integer 
frequencyNumber, final String frequencyType,
+            final String startDate, final Integer numberOfInstallments, final 
String dateFormat, final String locale) {
+        // given
+        final TestContext testContext = new TestContext();
+
+        final BatchRequest request = getBatchRequest(loanId, frequencyNumber, 
frequencyType, startDate, numberOfInstallments, dateFormat,
+                locale);
+
+        // Mock LoanScheduleData since it doesn't have a default constructor
+        final LoanScheduleData loanScheduleData = mock(LoanScheduleData.class);
+        final String responseBody = "{\"periods\":[]}";
+
+        
given(testContext.loanTransactionsApiResource.previewReAgeSchedule(eq(loanId), 
any(ReAgePreviewRequest.class)))
+                .willReturn(loanScheduleData);
+        
given(testContext.toApiJsonSerializer.serialize(eq(loanScheduleData))).willReturn(responseBody);
+
+        // when
+        final BatchResponse response = 
testContext.subjectToTest.execute(request, testContext.uriInfo);
+
+        // then
+        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+        assertThat(response.getRequestId()).isEqualTo(request.getRequestId());
+        assertThat(response.getHeaders()).isEqualTo(request.getHeaders());
+        assertThat(response.getBody()).isEqualTo(responseBody);
+
+        // Verify the API resource was called with correct parameters
+        
verify(testContext.loanTransactionsApiResource).previewReAgeSchedule(eq(loanId),
 testContext.reAgePreviewRequestCaptor.capture());
+
+        // Verify the serializer was invoked with the returned LoanScheduleData
+        
verify(testContext.toApiJsonSerializer).serialize(eq(loanScheduleData));
+
+        // Verify the ReAgePreviewRequest was built correctly from query 
parameters
+        final ReAgePreviewRequest capturedRequest = 
testContext.reAgePreviewRequestCaptor.getValue();
+        
assertThat(capturedRequest.getFrequencyNumber()).isEqualTo(frequencyNumber);
+        
assertThat(capturedRequest.getFrequencyType()).isEqualTo(frequencyType);
+        assertThat(capturedRequest.getStartDate()).isEqualTo(startDate);
+        
assertThat(capturedRequest.getNumberOfInstallments()).isEqualTo(numberOfInstallments);
+        assertThat(capturedRequest.getDateFormat()).isEqualTo(dateFormat);
+        assertThat(capturedRequest.getLocale()).isEqualTo(locale);
+    }
+
+    /**
+     * Creates and returns a batch request with the given parameters.
+     *
+     * @param loanId
+     *            the loan id
+     * @param frequencyNumber
+     *            the frequency number
+     * @param frequencyType
+     *            the frequency type
+     * @param startDate
+     *            the start date
+     * @param numberOfInstallments
+     *            the number of installments
+     * @param dateFormat
+     *            the date format
+     * @param locale
+     *            the locale
+     * @return BatchRequest
+     */
+    private BatchRequest getBatchRequest(final Long loanId, final Integer 
frequencyNumber, final String frequencyType,
+            final String startDate, final Integer numberOfInstallments, final 
String dateFormat, final String locale) {
+
+        final BatchRequest br = new BatchRequest();
+        String relativeUrl = "loans/" + loanId + "/transactions/reage-preview";
+
+        Set<String> queryParams = new HashSet<>();
+        queryParams.add("frequencyNumber=" + frequencyNumber);
+        queryParams.add("frequencyType=" + frequencyType);
+        queryParams.add("startDate=" + startDate);
+        queryParams.add("numberOfInstallments=" + numberOfInstallments);
+        queryParams.add("dateFormat=" + dateFormat);
+        queryParams.add("locale=" + locale);
+
+        relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+        br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setRelativeUrl(relativeUrl);
+        br.setMethod(HttpMethod.GET);
+        br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setBody("{}");
+
+        return br;
+    }
+
+    /**
+     * Creates and returns a batch request with WRONG parameter names to test 
validation.
+     *
+     * @param loanId
+     *            the loan id
+     * @return BatchRequest
+     */
+    private BatchRequest getBatchRequestWithWrongParameterNames(final Long 
loanId) {
+
+        final BatchRequest br = new BatchRequest();
+        String relativeUrl = "loans/" + loanId + "/transactions/reage-preview";
+
+        Set<String> queryParams = new HashSet<>();
+        // Using wrong parameter names
+        queryParams.add("frequencyNo=1"); // should be frequencyNumber
+        queryParams.add("freqType=MONTHS"); // should be frequencyType
+        queryParams.add("start=2024-01-15"); // should be startDate
+        queryParams.add("installments=12"); // should be numberOfInstallments
+        queryParams.add("format=dd MMMM yyyy"); // should be dateFormat
+        queryParams.add("lang=en"); // should be locale
+
+        relativeUrl = relativeUrl + "?" + String.join("&", queryParams);
+
+        br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setRelativeUrl(relativeUrl);
+        br.setMethod(HttpMethod.GET);
+        br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5)));
+        br.setBody("{}");
+
+        return br;
+    }
+
+    /**
+     * Private test context class used since testng runs in parallel to avoid 
state between tests
+     */
+    private static class TestContext {
+
+        /**
+         * The Mock UriInfo
+         */
+        @Mock
+        private UriInfo uriInfo;
+
+        /**
+         * The Mock {@link LoanTransactionsApiResource}
+         */
+        @Mock
+        private LoanTransactionsApiResource loanTransactionsApiResource;
+
+        /**
+         * The Mock {@link DefaultToApiJsonSerializer}
+         */
+        @Mock
+        private DefaultToApiJsonSerializer<LoanScheduleData> 
toApiJsonSerializer;
+
+        /**
+         * The Captor for ReAgePreviewRequest
+         */
+        @Captor
+        private ArgumentCaptor<ReAgePreviewRequest> reAgePreviewRequestCaptor;
+
+        /**
+         * The class under test.
+         */
+        private final GetReagePreviewByLoanIdCommandStrategy subjectToTest;
+
+        /**
+         * Constructor.
+         */
+        TestContext() {
+            MockitoAnnotations.openMocks(this);
+            subjectToTest = new 
GetReagePreviewByLoanIdCommandStrategy(loanTransactionsApiResource, 
toApiJsonSerializer);
+        }
+    }
+}

Reply via email to