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

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

commit ca354b4e4db43220fe7daf501d5ca28910997298
Author: Adam Saghy <[email protected]>
AuthorDate: Tue Jul 7 14:59:35 2026 +0200

    FINERACT-2679: Ensure backward compatible fineract-client method names
---
 .../check-generated-client-method-compatibility.py | 302 +++++++++++++++++++++
 .github/workflows/full-build-ci.yml                |   6 +
 ...y-generated-client-api-method-compatibility.yml |  90 ++++++
 .../src/main/resources/templates/java/api.mustache | 107 ++++++++
 .../src/main/resources/templates/java/api.mustache | 122 ++++++++-
 .../core/annotation/AlternativeOperationId.java    |  39 +++
 .../openapi/FineractOperationIdReader.java         | 130 ++++++++-
 .../openapi/FineractOperationIdReaderTest.java     | 137 ++++++++++
 .../integrationtests/client/IntegrationTest.java   |   2 +-
 9 files changed, 932 insertions(+), 3 deletions(-)

diff --git a/.github/scripts/check-generated-client-method-compatibility.py 
b/.github/scripts/check-generated-client-method-compatibility.py
new file mode 100644
index 0000000000..f6d57a5639
--- /dev/null
+++ b/.github/scripts/check-generated-client-method-compatibility.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Check generated Fineract client API method-name compatibility."""
+
+import argparse
+import os
+import re
+import sys
+from collections import OrderedDict
+from pathlib import Path
+
+
+CLIENT_API_DIRECTORIES = OrderedDict(
+    [
+        (
+            "fineract-client",
+            
Path("fineract-client/build/generated/java/src/main/java/org/apache/fineract/client/services"),
+        ),
+        (
+            "fineract-client-feign",
+            
Path("fineract-client-feign/build/generated/java/src/main/java/org/apache/fineract/client/feign/services"),
+        ),
+    ]
+)
+
+INTERFACE_PATTERN = re.compile(r"\binterface\s+([A-Za-z_$][\w$]*)\b")
+METHOD_PATTERN = re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
+TYPE_DECLARATION_PATTERN = re.compile(r"\b(class|interface|enum|record)\b")
+
+
+def strip_comments_and_literals(source):
+    result = []
+    i = 0
+    while i < len(source):
+        if source.startswith("//", i):
+            i += 2
+            while i < len(source) and source[i] != "\n":
+                i += 1
+            if i < len(source):
+                result.append("\n")
+                i += 1
+            continue
+
+        if source.startswith("/*", i):
+            i += 2
+            while i < len(source) and not source.startswith("*/", i):
+                if source[i] == "\n":
+                    result.append("\n")
+                i += 1
+            i += 2
+            continue
+
+        if source.startswith('"""', i):
+            result.append('""')
+            i += 3
+            while i < len(source) and not source.startswith('"""', i):
+                if source[i] == "\n":
+                    result.append("\n")
+                i += 1
+            i += 3
+            continue
+
+        if source[i] == '"':
+            result.append('""')
+            i += 1
+            escaped = False
+            while i < len(source):
+                char = source[i]
+                i += 1
+                if escaped:
+                    escaped = False
+                elif char == "\\":
+                    escaped = True
+                elif char == '"':
+                    break
+            continue
+
+        if source[i] == "'":
+            result.append("''")
+            i += 1
+            escaped = False
+            while i < len(source):
+                char = source[i]
+                i += 1
+                if escaped:
+                    escaped = False
+                elif char == "\\":
+                    escaped = True
+                elif char == "'":
+                    break
+            continue
+
+        result.append(source[i])
+        i += 1
+
+    return "".join(result)
+
+
+def strip_annotations(source):
+    result = []
+    i = 0
+    while i < len(source):
+        if source[i] != "@":
+            result.append(source[i])
+            i += 1
+            continue
+
+        i += 1
+        while i < len(source) and (source[i].isalnum() or source[i] in "_$."):
+            i += 1
+        while i < len(source) and source[i].isspace():
+            i += 1
+
+        if i < len(source) and source[i] == "(":
+            depth = 1
+            i += 1
+            while i < len(source) and depth > 0:
+                if source[i] == "(":
+                    depth += 1
+                elif source[i] == ")":
+                    depth -= 1
+                i += 1
+
+        result.append(" ")
+
+    return "".join(result)
+
+
+def method_name_from_member(member):
+    compact = " ".join(member.split())
+    if not compact or "(" not in compact:
+        return None
+    if TYPE_DECLARATION_PATTERN.search(compact):
+        return None
+
+    match = METHOD_PATTERN.search(compact)
+    if not match:
+        return None
+    return match.group(1)
+
+
+def extract_api_methods(java_file):
+    source = java_file.read_text(encoding="utf-8")
+    source = strip_annotations(strip_comments_and_literals(source))
+
+    interface_match = INTERFACE_PATTERN.search(source)
+    if not interface_match:
+        return set()
+
+    body_start = source.find("{", interface_match.end())
+    if body_start < 0:
+        return set()
+
+    methods = set()
+    depth = 1
+    member = []
+    i = body_start + 1
+    while i < len(source) and depth > 0:
+        char = source[i]
+        if depth == 1:
+            if char == "{":
+                name = method_name_from_member("".join(member))
+                if name:
+                    methods.add(name)
+                member = []
+                depth += 1
+            elif char == ";":
+                name = method_name_from_member("".join(member))
+                if name:
+                    methods.add(name)
+                member = []
+            elif char == "}":
+                depth -= 1
+                member = []
+            else:
+                member.append(char)
+        else:
+            if char == "{":
+                depth += 1
+            elif char == "}":
+                depth -= 1
+        i += 1
+
+    return methods
+
+
+def collect_api_surface(source_root):
+    if not source_root.is_dir():
+        raise FileNotFoundError(f"Generated API directory does not exist: 
{source_root}")
+
+    surface = OrderedDict()
+    for java_file in sorted(source_root.rglob("*Api.java")):
+        relative_path = java_file.relative_to(source_root).as_posix()
+        methods = extract_api_methods(java_file)
+        if methods:
+            surface[relative_path] = methods
+    return surface
+
+
+def compare_surfaces(baseline_root, current_root):
+    violations = []
+    for client_name, api_directory in CLIENT_API_DIRECTORIES.items():
+        baseline_surface = collect_api_surface(baseline_root / api_directory)
+        current_surface = collect_api_surface(current_root / api_directory)
+
+        for api_file, baseline_methods in baseline_surface.items():
+            current_methods = current_surface.get(api_file, set())
+            missing_methods = sorted(baseline_methods - current_methods)
+            if missing_methods:
+                violations.append((client_name, api_file, missing_methods))
+
+    return violations
+
+
+def markdown_methods(methods):
+    rendered = ", ".join(f"`{method}`" for method in methods[:20])
+    if len(methods) > 20:
+        rendered += f" +{len(methods) - 20} more"
+    return rendered
+
+
+def build_report(violations):
+    lines = ["## Generated Client API Method Compatibility", ""]
+    if not violations:
+        lines.append("No generated API method names were removed from 
`fineract-client` or `fineract-client-feign`.")
+        return "\n".join(lines) + "\n"
+
+    lines.extend(
+        [
+            "The generated client API method names are not backward 
compatible.",
+            "",
+            "Existing generated method names that disappear should be 
preserved with `alternativeOperationId`.",
+            "",
+            "| Client | API | Removed method names |",
+            "|--------|-----|----------------------|",
+        ]
+    )
+
+    for client_name, api_file, missing_methods in violations:
+        lines.append(f"| `{client_name}` | `{api_file}` | 
{markdown_methods(missing_methods)} |")
+
+    lines.extend(
+        [
+            "",
+            f"**Total: {sum(len(methods) for _, _, methods in violations)} 
removed method names across {len(violations)} API files.**",
+        ]
+    )
+    return "\n".join(lines) + "\n"
+
+
+def append_step_summary(report):
+    summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
+    if summary_file:
+        with open(summary_file, "a", encoding="utf-8") as output:
+            output.write(report)
+
+
+def parse_args():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--baseline-root", required=True, type=Path)
+    parser.add_argument("--current-root", required=True, type=Path)
+    parser.add_argument("--report-file", required=True, type=Path)
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    violations = compare_surfaces(args.baseline_root, args.current_root)
+    report = build_report(violations)
+
+    args.report_file.write_text(report, encoding="utf-8")
+    append_step_summary(report)
+    print(report)
+
+    if violations:
+        print(
+            "::error::Generated client API method names were removed. "
+            "Add alternativeOperationId entries to preserve 
backward-compatible methods."
+        )
+        return 1
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/.github/workflows/full-build-ci.yml 
b/.github/workflows/full-build-ci.yml
index b75c43becf..093857e948 100644
--- a/.github/workflows/full-build-ci.yml
+++ b/.github/workflows/full-build-ci.yml
@@ -121,6 +121,12 @@ jobs:
     secrets:
       DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
 
+  run-generated-client-api-method-compatibility:
+    needs: build-core
+    uses: 
./.github/workflows/verify-generated-client-api-method-compatibility.yml
+    secrets:
+      DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+
   run-liquibase-backward-compatibility:
     needs: build-core
     uses: ./.github/workflows/verify-liquibase-backward-compatibility.yml
diff --git 
a/.github/workflows/verify-generated-client-api-method-compatibility.yml 
b/.github/workflows/verify-generated-client-api-method-compatibility.yml
new file mode 100644
index 0000000000..21e9208757
--- /dev/null
+++ b/.github/workflows/verify-generated-client-api-method-compatibility.yml
@@ -0,0 +1,90 @@
+name: Verify Generated Client API Method Compatibility
+
+on:
+  workflow_call:
+    secrets:
+      DEVELOCITY_ACCESS_KEY:
+        required: false
+
+permissions:
+  contents: read
+
+jobs:
+  generated-client-method-compatibility-check:
+    if: ${{ github.event_name == 'pull_request' }}
+    runs-on: ubuntu-24.04
+    timeout-minutes: 60
+
+    env:
+      TZ: Asia/Kolkata
+      DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+
+    steps:
+      - name: Checkout base branch for baseline
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 
v7.0.0
+        with:
+          persist-credentials: false
+          repository: ${{ github.event.pull_request.base.repo.full_name }}
+          ref: ${{ github.event.pull_request.base.ref }}
+          fetch-depth: 0
+          path: baseline
+
+      - name: Checkout base branch for merged PR
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 
v7.0.0
+        with:
+          persist-credentials: false
+          repository: ${{ github.event.pull_request.base.repo.full_name }}
+          ref: ${{ github.event.pull_request.base.ref }}
+          fetch-depth: 0
+          path: current
+
+      - name: Merge PR into current base
+        working-directory: current
+        env:
+          PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+        run: |
+          git config user.name "github-actions"
+          git config user.email "[email protected]"
+
+          case "$PR_HEAD_SHA" in
+            ""|*[!0-9a-fA-F]*)
+              echo "::error::Invalid pull request head SHA: $PR_HEAD_SHA"
+              exit 1
+              ;;
+          esac
+
+          git fetch "https://github.com/${PR_HEAD_REPO}.git"; "$PR_HEAD_SHA" 
--no-tags
+
+          git merge --no-commit --no-ff FETCH_HEAD
+
+      - name: Set up JDK 21
+        uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
+        with:
+          distribution: 'zulu'
+          java-version: '21'
+
+      - name: Generate baseline clients
+        working-directory: baseline
+        run: ./gradlew --no-daemon :fineract-client:cleanupGeneratedJavaFiles 
:fineract-client-feign:cleanupGeneratedJavaFiles
+
+      - name: Generate merged PR clients
+        working-directory: current
+        run: ./gradlew --no-daemon :fineract-client:cleanupGeneratedJavaFiles 
:fineract-client-feign:cleanupGeneratedJavaFiles
+
+      - name: Check generated client API method compatibility
+        run: |
+          python3 
current/.github/scripts/check-generated-client-method-compatibility.py \
+            --baseline-root "${GITHUB_WORKSPACE}/baseline" \
+            --current-root "${GITHUB_WORKSPACE}/current" \
+            --report-file 
"${GITHUB_WORKSPACE}/generated-client-method-compatibility-report.md"
+
+      - name: Archive generated client compatibility report
+        if: failure()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7.0.1
+        with:
+          name: generated-client-method-compatibility-report
+          path: generated-client-method-compatibility-report.md
+          if-no-files-found: ignore
+          retention-days: 5
+          compression-level: 9
diff --git 
a/fineract-client-feign/src/main/resources/templates/java/api.mustache 
b/fineract-client-feign/src/main/resources/templates/java/api.mustache
index 47f42a5244..093f713d55 100644
--- a/fineract-client-feign/src/main/resources/templates/java/api.mustache
+++ b/fineract-client-feign/src/main/resources/templates/java/api.mustache
@@ -103,6 +103,38 @@ public interface {{classname}} {
   })
   
ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}>
 
{{nickname}}WithHttpInfo({{#allParams}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}")
 {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", 
expander=ParamExpander.class) 
{{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") 
{{/isFormParam}}{{/isBodyParam}}{{{dataType}}} {{paramName}}, 
{{/allParams}}@HeaderMap Map<String, String> headers);
 
+{{#vendorExtensions.x-alternative-operation-id}}
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}</code>.
+   * @deprecated Use <code>{{operationId}}</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{{dataType}}} 
{{paramName}}, {{/allParams}}Map<String, String> headers) {
+    {{#returnType}}return 
{{/returnType}}{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}headers);
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}</code> with empty headers.
+   * @deprecated Use <code>{{operationId}}</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{{dataType}}} 
{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) {
+    {{#returnType}}return 
{{/returnType}}{{nickname}}({{#allParams}}{{paramName}}{{^-last}}, 
{{/-last}}{{/allParams}});
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}WithHttpInfo</code>.
+   * @deprecated Use <code>{{operationId}}WithHttpInfo</code> instead.
+   */
+  @Deprecated
+  default 
ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}>
 
{{vendorExtensions.x-alternative-operation-id}}WithHttpInfo({{#allParams}}{{{dataType}}}
 {{paramName}}, {{/allParams}}Map<String, String> headers) {
+    return {{nickname}}WithHttpInfo({{#allParams}}{{paramName}}, 
{{/allParams}}headers);
+  }
+
+{{/vendorExtensions.x-alternative-operation-id}}
 
   {{#hasQueryParams}}
   /**
@@ -257,6 +289,58 @@ public interface {{classname}} {
       })
    
ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}>
 
{{nickname}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}")
 {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", 
expander=ParamExpander.class) 
{{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") 
{{/isFormParam}}{{/isBodyParam}}{{{dataType}}} {{paramName}}, 
{{/isQueryParam}}{{/allParams}}@QueryMap(encoded=tr [...]
 
+{{#vendorExtensions.x-alternative-operation-id}}
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}</code> using typed query parameters.
+   * @deprecated Use <code>{{operationId}}</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, 
{{/isQueryParam}}{{/allParams}}{{operationIdCamelCase}}QueryParams queryParams, 
Map<String, String> headers) {
+    {{#returnType}}return 
{{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, 
{{/isQueryParam}}{{/allParams}}queryParams, headers);
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}</code> using typed query parameters and 
empty headers.
+   * @deprecated Use <code>{{operationId}}</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, 
{{/isQueryParam}}{{/allParams}}{{operationIdCamelCase}}QueryParams queryParams) 
{
+    {{#returnType}}return 
{{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, 
{{/isQueryParam}}{{/allParams}}queryParams);
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}</code> using generic query parameters.
+   * @deprecated Use <code>{{operationId}}</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams, 
Map<String, String> headers) {
+    {{#returnType}}return 
{{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, 
{{/isQueryParam}}{{/allParams}}queryParams, headers);
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}</code> using generic query parameters and 
empty headers.
+   * @deprecated Use <code>{{operationId}}</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams) 
{
+    {{#returnType}}return 
{{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, 
{{/isQueryParam}}{{/allParams}}queryParams);
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}WithHttpInfo</code> using typed query 
parameters.
+   * @deprecated Use <code>{{operationId}}WithHttpInfo</code> instead.
+   */
+  @Deprecated
+  default 
ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}>
 
{{vendorExtensions.x-alternative-operation-id}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, 
{{/isQueryParam}}{{/allParams}}{{operationIdCamelCase}}QueryParams queryParams, 
Map<String, String> headers) {
+    return 
{{nickname}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{paramName}}, 
{{/isQueryParam}}{{/allParams}}queryParams, headers);
+  }
+
+{{/vendorExtensions.x-alternative-operation-id}}
 
    /**
    * A convenience class for generating query parameters for the
@@ -321,6 +405,29 @@ public interface {{classname}} {
     {{#returnType}}return 
{{/returnType}}{{nickname}}Universal({{#allParams}}{{^isQueryParam}}{{paramName}},
 {{/isQueryParam}}{{/allParams}}queryParams, Map.of());
   }
 
+{{#vendorExtensions.x-alternative-operation-id}}
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}Universal</code>.
+   * @deprecated Use <code>{{operationId}}Universal</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}Universal({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams, 
Map<String, String> headers) {
+    {{#returnType}}return 
{{/returnType}}{{nickname}}Universal({{#allParams}}{{^isQueryParam}}{{paramName}},
 {{/isQueryParam}}{{/allParams}}queryParams, headers);
+  }
+
+  /**
+   * {{summary}}
+   * Alias for <code>{{operationId}}Universal</code> with empty headers.
+   * @deprecated Use <code>{{operationId}}Universal</code> instead.
+   */
+  @Deprecated
+  default 
{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} 
{{vendorExtensions.x-alternative-operation-id}}Universal({{#allParams}}{{^isQueryParam}}{{{dataType}}}
 {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams) 
{
+    {{#returnType}}return 
{{/returnType}}{{nickname}}Universal({{#allParams}}{{^isQueryParam}}{{paramName}},
 {{/isQueryParam}}{{/allParams}}queryParams);
+  }
+
+{{/vendorExtensions.x-alternative-operation-id}}
+
   {{/operation}}
 {{/operations}}
 }
diff --git a/fineract-client/src/main/resources/templates/java/api.mustache 
b/fineract-client/src/main/resources/templates/java/api.mustache
index e16793e38d..4a5a691470 100644
--- a/fineract-client/src/main/resources/templates/java/api.mustache
+++ b/fineract-client/src/main/resources/templates/java/api.mustache
@@ -73,6 +73,36 @@ public interface {{classname}} {
     @{{httpMethod}}("{{{path}}}")
     
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
 
+{{#vendorExtensions.x-alternative-operation-id}}
+    /**
+    * {{summary}}
+    * Alias for <code>{{operationId}}</code>.
+    {{#allParams}}
+        * @param {{paramName}} {{description}}{{#required}} 
(required){{/required}}{{^required}} (optional{{#defaultValue}}, default to 
{{.}}{{/defaultValue}}){{/required}}
+    {{/allParams}}
+    * @return 
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doN
 [...]
+    * @deprecated Use <code>{{operationId}}</code> instead.
+    */
+    @Deprecated
+    {{#formParams}}
+        {{#-first}}
+            
{{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+        {{/-first}}
+    {{/formParams}}
+    {{^formParams}}
+        {{#prioritizedContentTypes}}
+            {{#-first}}
+                @Headers({
+                "Content-Type:{{{mediaType}}}"
+                })
+            {{/-first}}
+        {{/prioritizedContentTypes}}
+    {{/formParams}}
+    @{{httpMethod}}("{{{path}}}")
+    
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
+
 {{/operation}}
 
 {{#operation}}
@@ -113,6 +143,35 @@ public interface {{classname}} {
     {{/formParams}}
     @{{httpMethod}}("{{{path}}}")
     
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+        /**
+        * {{summary}}
+        * Alias for <code>{{operationId}}</code>.
+    {{#allParams}}
+            * @param {{paramName}} {{description}}{{#required}} 
(required){{/required}}{{^required}} (optional{{#defaultValue}}, default to 
{{.}}{{/defaultValue}}){{/required}}
+    {{/allParams}}
+        * @return 
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{
 [...]
+        * @deprecated Use <code>{{operationId}}</code> instead.
+        */
+        @Deprecated
+    {{#formParams}}
+        {{#-first}}
+            
{{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+        {{/-first}}
+    {{/formParams}}
+    {{^formParams}}
+        {{#prioritizedContentTypes}}
+            {{#-first}}
+                    @Headers({
+                    "Content-Type:{{{mediaType}}}"
+                    })
+            {{/-first}}
+        {{/prioritizedContentTypes}}
+    {{/formParams}}
+    @{{httpMethod}}("{{{path}}}")
+    
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
             {{/required}}
         {{/isBodyParam}}
     {{/allParams}}
@@ -154,6 +213,37 @@ public interface {{classname}} {
     @{{httpMethod}}("{{{path}}}")
     
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
 
+{{#vendorExtensions.x-alternative-operation-id}}
+        /**
+        * {{summary}}
+        * Alias for <code>{{operationId}}</code> with request headers.
+    {{#allParams}}
+            * @param {{paramName}} {{description}}{{#required}} 
(required){{/required}}{{^required}} (optional{{#defaultValue}}, default to 
{{.}}{{/defaultValue}}){{/required}}
+    {{/allParams}}
+        * @param headers Custom headers to include in the request
+        * @return 
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{
 [...]
+        * @deprecated Use <code>{{operationId}}</code> instead.
+        */
+        @Deprecated
+    {{#formParams}}
+        {{#-first}}
+            
{{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+        {{/-first}}
+    {{/formParams}}
+    {{^formParams}}
+        {{#prioritizedContentTypes}}
+            {{#-first}}
+                    @Headers({
+                    "Content-Type:{{{mediaType}}}"
+                    })
+            {{/-first}}
+        {{/prioritizedContentTypes}}
+    {{/formParams}}
+    @{{httpMethod}}("{{{path}}}")
+    
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
+
 {{/operation}}
 
 {{#operation}}
@@ -194,9 +284,39 @@ public interface {{classname}} {
     {{/formParams}}
     @{{httpMethod}}("{{{path}}}")
     
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+        /**
+        * {{summary}}
+        * Alias for <code>{{operationId}}</code> with request headers.
+    {{#allParams}}
+            * @param {{paramName}} {{description}}{{#required}} 
(required){{/required}}{{^required}} (optional{{#defaultValue}}, default to 
{{.}}{{/defaultValue}}){{/required}}
+    {{/allParams}}
+        * @param headers Custom headers to include in the request
+        * @return 
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable&lt;{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}&gt;{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{
 [...]
+        * @deprecated Use <code>{{operationId}}</code> instead.
+        */
+        @Deprecated
+    {{#formParams}}
+        {{#-first}}
+            
{{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+        {{/-first}}
+    {{/formParams}}
+    {{^formParams}}
+        {{#prioritizedContentTypes}}
+            {{#-first}}
+                    @Headers({
+                    "Content-Type:{{{mediaType}}}"
+                    })
+            {{/-first}}
+        {{/prioritizedContentTypes}}
+    {{/formParams}}
+    @{{httpMethod}}("{{{path}}}")
+    
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i
 [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
             {{/required}}
         {{/isBodyParam}}
     {{/allParams}}
 {{/operation}}
 }
-{{/operations}}
\ No newline at end of file
+{{/operations}}
diff --git 
a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/AlternativeOperationId.java
 
b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/AlternativeOperationId.java
new file mode 100644
index 0000000000..585ada4249
--- /dev/null
+++ 
b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/AlternativeOperationId.java
@@ -0,0 +1,39 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.infrastructure.core.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Adds an alternate generated client method name for an OpenAPI operation.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AlternativeOperationId {
+
+    /**
+     * Java method name to generate as an alias for the Swagger {@code 
operationId}.
+     *
+     * @return the alternate operation ID
+     */
+    String value();
+}
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
 
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
index 4721d10fd2..000ff1a108 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
@@ -30,14 +30,21 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import javax.lang.model.SourceVersion;
+import 
org.apache.fineract.infrastructure.core.annotation.AlternativeOperationId;
 
 public final class FineractOperationIdReader extends Reader {
 
+    public static final String ALTERNATIVE_OPERATION_ID_EXTENSION = 
"x-alternative-operation-id";
+
     // Check explicit @Operation ids first, then let Swagger build the spec.
     @Override
     public OpenAPI read(Set<Class<?>> classes, Map<String, Object> resources) {
         ExplicitOperationValidator.validate(classes);
-        return super.read(classes, resources);
+        OpenAPI openAPI = super.read(classes, resources);
+        OperationIdValidator.validate(openAPI);
+        AlternativeOperationIdExtension.apply(classes, openAPI);
+        return openAPI;
     }
 
     static final class ExplicitOperationValidator {
@@ -74,6 +81,127 @@ public final class FineractOperationIdReader extends Reader 
{
         }
     }
 
+    static final class OperationIdValidator {
+
+        static void validate(OpenAPI openAPI) {
+            Map<String, List<String>> operationIds = 
collectOperationIds(openAPI);
+
+            List<String> duplicates = 
operationIds.entrySet().stream().filter(e -> e.getValue().size() > 1)
+                    .map(e -> e.getKey() + " -> " + String.join(", ", 
e.getValue())).sorted().toList();
+            if (!duplicates.isEmpty()) {
+                throw new IllegalStateException("Duplicate OpenAPI 
operationIds detected:\n - " + String.join("\n - ", duplicates));
+            }
+        }
+
+        static Map<String, List<String>> collectOperationIds(OpenAPI openAPI) {
+            Map<String, List<String>> operationIds = new LinkedHashMap<>();
+            if (openAPI == null || openAPI.getPaths() == null) {
+                return operationIds;
+            }
+
+            openAPI.getPaths().forEach((path, pathItem) -> 
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
+                String operationId = trimToNull(operation.getOperationId());
+                if (operationId != null) {
+                    operationIds.computeIfAbsent(operationId, ignored -> new 
ArrayList<>()).add(httpMethod + " " + path);
+                }
+            }));
+            return operationIds;
+        }
+    }
+
+    static final class AlternativeOperationIdExtension {
+
+        static void apply(Set<Class<?>> classes, OpenAPI openAPI) {
+            Map<String, String> alternativeOperationIds = collect(classes);
+            Map<String, List<String>> operationIds = 
OperationIdValidator.collectOperationIds(openAPI);
+            validateAlternativeOperationIdTargets(alternativeOperationIds, 
operationIds);
+            validateNoCollisionWithOperationIds(alternativeOperationIds, 
operationIds);
+
+            if (alternativeOperationIds.isEmpty() || openAPI == null || 
openAPI.getPaths() == null) {
+                return;
+            }
+
+            openAPI.getPaths().values().forEach(pathItem -> 
pathItem.readOperations().forEach(operation -> {
+                String alternativeOperationId = 
alternativeOperationIds.get(operation.getOperationId());
+                if (alternativeOperationId != null) {
+                    operation.addExtension(ALTERNATIVE_OPERATION_ID_EXTENSION, 
alternativeOperationId);
+                }
+            }));
+        }
+
+        private static Map<String, String> collect(Set<Class<?>> classes) {
+            Map<String, String> alternativeOperationIds = new 
LinkedHashMap<>();
+            Map<String, List<String>> duplicateAlternativeOperationIds = new 
LinkedHashMap<>();
+
+            for (Class<?> resourceClass : classes) {
+                if (resourceClass.getAnnotation(Path.class) == null) {
+                    continue;
+                }
+
+                for (Method method : resourceClass.getMethods()) {
+                    Operation operation = 
method.getAnnotation(Operation.class);
+                    String operationId = trimToNull(operation == null ? null : 
operation.operationId());
+
+                    AlternativeOperationId alternativeOperationId = 
method.getAnnotation(AlternativeOperationId.class);
+                    if (alternativeOperationId == null) {
+                        continue;
+                    }
+
+                    String source = resourceClass.getSimpleName() + "#" + 
method.getName();
+                    if (!hasHttpMethod(method)) {
+                        throw new 
IllegalStateException("@AlternativeOperationId can only be used on JAX-RS 
resource methods: " + source);
+                    }
+
+                    String value = trimToNull(alternativeOperationId.value());
+                    if (value == null) {
+                        throw new 
IllegalStateException("@AlternativeOperationId value must not be blank: " + 
source);
+                    }
+                    if (!SourceVersion.isIdentifier(value) || 
SourceVersion.isKeyword(value)) {
+                        throw new IllegalStateException(
+                                "@AlternativeOperationId must be a valid Java 
method name: " + source + " -> " + value);
+                    }
+
+                    String resolvedOperationId = operationId == null ? 
method.getName() : operationId;
+                    if (resolvedOperationId.equals(value)) {
+                        continue;
+                    }
+
+                    duplicateAlternativeOperationIds.computeIfAbsent(value, 
ignored -> new ArrayList<>()).add(source);
+                    alternativeOperationIds.put(resolvedOperationId, value);
+                }
+            }
+
+            List<String> duplicates = 
duplicateAlternativeOperationIds.entrySet().stream().filter(e -> 
e.getValue().size() > 1)
+                    .map(e -> e.getKey() + " -> " + String.join(", ", 
e.getValue())).sorted().toList();
+            if (!duplicates.isEmpty()) {
+                throw new IllegalStateException(
+                        "Duplicate explicit OpenAPI alternativeOperationIds 
detected:\n - " + String.join("\n - ", duplicates));
+            }
+            return alternativeOperationIds;
+        }
+
+        private static void validateAlternativeOperationIdTargets(Map<String, 
String> alternativeOperationIds,
+                Map<String, List<String>> operationIds) {
+            List<String> missing = 
alternativeOperationIds.keySet().stream().filter(operationId -> 
!operationIds.containsKey(operationId))
+                    .sorted().toList();
+            if (!missing.isEmpty()) {
+                throw new IllegalStateException(
+                        "OpenAPI alternativeOperationIds could not be matched 
to operationIds:\n - " + String.join("\n - ", missing));
+            }
+        }
+
+        private static void validateNoCollisionWithOperationIds(Map<String, 
String> alternativeOperationIds,
+                Map<String, List<String>> operationIds) {
+            List<String> conflicts = 
alternativeOperationIds.entrySet().stream().filter(e -> 
operationIds.containsKey(e.getValue()))
+                    .map(e -> e.getKey() + " -> " + e.getValue() + " conflicts 
with " + String.join(", ", operationIds.get(e.getValue())))
+                    .sorted().toList();
+            if (!conflicts.isEmpty()) {
+                throw new IllegalStateException(
+                        "OpenAPI alternativeOperationIds conflict with 
operationIds:\n - " + String.join("\n - ", conflicts));
+            }
+        }
+    }
+
     // GET, POST, etc. are meta-annotated with @HttpMethod.
     private static boolean hasHttpMethod(Method method) {
         for (Annotation annotation : method.getAnnotations()) {
diff --git 
a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReaderTest.java
 
b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReaderTest.java
new file mode 100644
index 0000000000..f58e40ce5b
--- /dev/null
+++ 
b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReaderTest.java
@@ -0,0 +1,137 @@
+/**
+ * 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.infrastructure.openapi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.Paths;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import java.util.Map;
+import java.util.Set;
+import 
org.apache.fineract.infrastructure.core.annotation.AlternativeOperationId;
+import org.junit.jupiter.api.Test;
+
+class FineractOperationIdReaderTest {
+
+    @Test
+    void addsAlternativeOperationIdExtension() {
+        OpenAPI openAPI = new 
FineractOperationIdReader().read(Set.of(ResourceWithAlternativeOperationId.class),
 Map.of());
+
+        Object alternativeOperationId = 
openAPI.getPaths().get("/test").getGet().getExtensions()
+                
.get(FineractOperationIdReader.ALTERNATIVE_OPERATION_ID_EXTENSION);
+
+        assertEquals("retrieveTestByLegacyName", alternativeOperationId);
+    }
+
+    @Test
+    void addsAlternativeOperationIdExtensionForImplicitOperationId() {
+        OpenAPI openAPI = new 
FineractOperationIdReader().read(Set.of(ResourceWithImplicitOperationIdAlternative.class),
 Map.of());
+
+        Object alternativeOperationId = 
openAPI.getPaths().get("/implicit").getGet().getExtensions()
+                
.get(FineractOperationIdReader.ALTERNATIVE_OPERATION_ID_EXTENSION);
+
+        assertEquals("retrieveImplicitTestByLegacyName", 
alternativeOperationId);
+    }
+
+    @Test
+    void rejectsInvalidAlternativeOperationId() {
+        IllegalStateException exception = 
assertThrows(IllegalStateException.class,
+                () -> new 
FineractOperationIdReader().read(Set.of(ResourceWithInvalidAlternativeOperationId.class),
 Map.of()));
+
+        assertTrue(exception.getMessage().contains("valid Java method name"));
+    }
+
+    @Test
+    void rejectsAlternativeOperationIdConflictingWithImplicitOperationId() {
+        IllegalStateException exception = 
assertThrows(IllegalStateException.class, () -> new FineractOperationIdReader()
+                .read(Set.of(ResourceWithAlternativeOperationIdConflict.class, 
ResourceWithImplicitOperationId.class), Map.of()));
+
+        assertTrue(exception.getMessage().contains("alternativeOperationIds 
conflict with operationIds"));
+    }
+
+    @Test
+    void rejectsDuplicateFinalOperationIds() {
+        OpenAPI openAPI = new OpenAPI().paths(new Paths()
+                .addPathItem("/first", new PathItem().get(new 
io.swagger.v3.oas.models.Operation().operationId("duplicateOperation")))
+                .addPathItem("/second", new PathItem().post(new 
io.swagger.v3.oas.models.Operation().operationId("duplicateOperation"))));
+
+        IllegalStateException exception = 
assertThrows(IllegalStateException.class,
+                () -> 
FineractOperationIdReader.OperationIdValidator.validate(openAPI));
+
+        assertTrue(exception.getMessage().contains("Duplicate OpenAPI 
operationIds"));
+    }
+
+    @Path("/test")
+    public static class ResourceWithAlternativeOperationId {
+
+        @GET
+        @Operation(operationId = "retrieveTest")
+        @AlternativeOperationId("retrieveTestByLegacyName")
+        public String retrieveTest() {
+            return "test";
+        }
+    }
+
+    @Path("/implicit")
+    public static class ResourceWithImplicitOperationIdAlternative {
+
+        @GET
+        @AlternativeOperationId("retrieveImplicitTestByLegacyName")
+        public String retrieveImplicitTest() {
+            return "implicit";
+        }
+    }
+
+    @Path("/invalid")
+    public static class ResourceWithInvalidAlternativeOperationId {
+
+        @GET
+        @Operation(operationId = "retrieveInvalid")
+        @AlternativeOperationId("class")
+        public String retrieveInvalid() {
+            return "invalid";
+        }
+    }
+
+    @Path("/conflict")
+    public static class ResourceWithAlternativeOperationIdConflict {
+
+        @GET
+        @Operation(operationId = "retrieveConflict")
+        @AlternativeOperationId("retrieveImplicitConflict")
+        public String retrieveConflict() {
+            return "conflict";
+        }
+    }
+
+    @Path("/implicit-conflict")
+    public static class ResourceWithImplicitOperationId {
+
+        @GET
+        public String retrieveImplicitConflict() {
+            return "implicit-conflict";
+        }
+    }
+}
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
index 4031caeb5d..180e7ea201 100644
--- 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
@@ -133,7 +133,7 @@ public abstract class IntegrationTest {
         try {
             
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE,
                     new PutGlobalConfigurationsRequest().enabled(true));
-            businessDateHelper.updateBusinessDate(new 
BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
+            BusinessDateHelper.updateBusinessDate(new 
BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
                     .date(date).dateFormat(DATETIME_PATTERN).locale("en"));
             runnable.run();
         } finally {

Reply via email to