loadRequest(final Long id) {
- try {
- return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id);
- } catch (final EmptyResultDataAccessException e) {
- throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found",
- "Savings-debit request " + id + " does not exist.");
- }
- }
-
- private Long requireGl(final Long productId, final int financialAccountType, final String label) {
- try {
- final Long glId = this.jdbcTemplate.queryForObject(
- "SELECT gl_account_id FROM acc_product_mapping "
- + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
- Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
- if (glId == null) {
- throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
- label + " GL is not mapped for share product " + productId + ".");
- }
- return glId;
- } catch (final IncorrectResultSizeDataAccessException e) {
- throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
- label + " GL is not mapped for share product " + productId + ".");
- }
- }
-
- private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
- final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
- final String description) {
- this.jdbcTemplate.update(
- "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
- + " entry_date, type_enum, amount, description, entity_id, "
- + " created_by, last_modified_by, created_date, lastmodified_date, "
- + " is_running_balance_calculated, office_running_balance, "
- + " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) "
- + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
- glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
- userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
- }
-
- /**
- * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
- *
- * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
- * 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's
- * own zone first, which is the zone {@code now} was taken in.
- */
- private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
- return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
- .withOffsetSameInstant(ZoneOffset.UTC);
- }
-
- private void validateCheckerRole(final AppUser user) {
- if (user.hasAnyPermission("ALL_FUNCTIONS")) {
- return;
- }
- final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
- .anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
- if (!isChecker) {
- throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required",
- "Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request.");
- }
- }
-
- private JsonObject parseBody(final String body) {
- if (body == null || body.isBlank()) {
- throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required.");
- }
- try {
- return JsonParser.parseString(body).getAsJsonObject();
- } catch (final RuntimeException e) {
- throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON.");
- }
- }
-
- private Long getLong(final JsonObject json, final String field) {
- return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
- }
-
- private String getString(final JsonObject json, final String field) {
- if (!json.has(field) || json.get(field).isJsonNull()) {
- return null;
- }
- final String value = json.get(field).getAsString().trim();
- return value.isEmpty() ? null : value;
- }
-
- private Long toLong(final Object o) {
- return o instanceof Number n ? n.longValue() : null;
- }
-
- private int toInt(final Object o) {
- return o instanceof Number n ? n.intValue() : 0;
- }
-}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java
index 709959d15..1b6345cc4 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java
@@ -45,14 +45,9 @@ import org.springframework.stereotype.Component;
* Can a member fund a new additional-share tranche from their savings, and for how much.
*
*
- * Distinct from {@code /sharesavingsdebit/eligibility}, which answers the same question for an account's first
- * application: that endpoint values the settlement by summing every purchase row on the account and treats
- * {@code collection_applied} as the account's one settlement. Applied to a further tranche it would quote the member's
- * entire holding history and report an already-settled account as ineligible.
- *
- *
- * Here the amount is the caller's — the tranche is not saved yet, so there is no row to read it from. The share count is
- * passed in and priced at the product's current market price, which is the same figure the apply form shows the member.
+ * Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the
+ * caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and
+ * priced at the product's current market price, which is the same figure the apply form shows the member.
*/
@Path("/v1/shareaccounts/{accountId}/additionalshares")
@Component
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java
new file mode 100644
index 000000000..66d369a66
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java
@@ -0,0 +1,196 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.portfolio.shareaccounts.api;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * Additional-share tranches awaiting approval, across every share account, for a centralized Approval Center queue.
+ *
+ *
+ * A tranche raised through "Apply Additional Shares" today is only visible for approval on its own account's page
+ * (the native {@code approveadditionalshares}/{@code rejectadditionalshares} commands, invoked directly from a button
+ * there) — there was no single place a checker could see every tranche waiting across the whole membership. This
+ * resource is the read side of that queue: one list of every pending tranche, and one detail view per tranche with
+ * everything a checker needs to decide, including the route-specific proof of funding a list row cannot show — which
+ * voucher(s) a cash tranche is resting on, or which savings account a savings tranche already debited.
+ *
+ *
+ * Approval and rejection themselves are NOT duplicated here — they still go through Fineract's own
+ * {@code approveadditionalshares}/{@code rejectadditionalshares} commands (same as the account-page buttons), so the
+ * journal postings and {@link org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService}
+ * settle/unwind hooks that already run on those commands run exactly once, from exactly one code path.
+ */
+@Path("/v1/shareadditionrequests")
+@Component
+@Tag(name = "Share Addition Requests", description = "Additional-share tranches awaiting approval, across every share account.")
+@RequiredArgsConstructor
+@Consumes({ MediaType.APPLICATION_JSON })
+@Produces({ MediaType.APPLICATION_JSON })
+public class ShareAdditionRequestsApiResource {
+
+ /** m_share_account_transactions.status_enum for "submitted, awaiting approval". */
+ private static final int STATUS_APPLIED = 100;
+ /** m_share_account_transactions.type_enum for a share purchase (an original application or a tranche alike). */
+ private static final int TYPE_PURCHASED = 500;
+
+ private static final String FUNDING_CASH = "CASH";
+ private static final String FUNDING_SAVINGS = "SAVINGS";
+
+ private final PlatformSecurityContext context;
+ private final JdbcTemplate jdbcTemplate;
+ private final Gson gson = new GsonBuilder().serializeNulls().create();
+
+ // Every alias is double-quoted: PostgreSQL folds an unquoted alias to lower case, so
+ // queryForList would key the maps "shareaccountid" etc., which serialise straight into JSON and
+ // leave the client reading shareAccountId as undefined.
+ private static final String BASE_SELECT = """
+ SELECT t.id AS "transactionId",
+ sa.id AS "shareAccountId",
+ sa.account_no AS "accountNo",
+ sa.client_id AS "clientId",
+ c.display_name AS "clientName",
+ o.name AS "officeName",
+ sp.name AS "productName",
+ sa.currency_code AS "currencyCode",
+ t.transaction_date AS "transactionDate",
+ t.total_shares AS "totalShares",
+ t.unit_price AS "unitPrice",
+ t.amount AS "amount",
+ t.charge_amount AS "chargeAmount",
+ t.resolution_number AS "resolutionNumber",
+ t.meeting_date AS "meetingDate",
+ t.funding_source AS "fundingSource",
+ t.advance_collection_id AS "advanceCollectionId",
+ t.additional_collection_ids AS "additionalCollectionIds"
+ FROM m_share_account_transactions t
+ JOIN m_share_account sa ON sa.id = t.account_id
+ JOIN m_client c ON c.id = sa.client_id
+ JOIN m_office o ON o.id = c.office_id
+ JOIN m_share_product sp ON sp.id = sa.product_id
+ """;
+
+ @GET
+ @Operation(summary = "List additional-share tranches awaiting approval",
+ description = "Every pending tranche across every share account, with the member, account, shares and value a checker needs to see before deciding.")
+ public Response retrievePending() {
+ this.context.authenticatedUser();
+ final List