diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java new file mode 100644 index 000000000..6391f8268 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java @@ -0,0 +1,330 @@ +/** + * 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.client.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredentialService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; +import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData; +import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +/** + * A single client's full deposit + share picture, looked up by client number OR mobile number (the + * path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) rather + * than the internal client id -- these are what a counter clerk has on the slip, or what a third-party + * caller was given, respectively, so no separate client-search step is needed before this call. Built + * so the whole thing, including the client lookup itself, resolves in a small handful of index-scan-only + * queries, on the theory that this is exactly the kind of check a teller or manager runs right before + * approving something (a share purchase, a closure, a large withdrawal) -- the "is there a problem here" + * question this endpoint answers has to come back before that decision is made, not after. + * + * Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData} + * (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to + * m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this + * caller doesn't need -- account number, product, status and the current balance/share value is the + * whole ask. Fewer joins and fewer columns is what actually makes this fast, not a different query + * engine. + * + * Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as + * they do every other endpoint; a third-party integration that should never see a Fineract username or + * password sends an {@code X-Api-Key} header instead (see {@link ApiClientCredentialService}), minted + * via {@code POST /v1/apiclients} by a super user. This path is carved out as {@code permitAll()} in + * {@code SecurityConfig} for exactly that second case -- the same posture this fork already uses for the + * IMPS and NPCI receivers, where Spring Security's outer gate stays open for one narrow path and the + * handler itself is what actually authenticates the caller. + */ +@Path("/v1/clientaccounts") +@Component +@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of every Savings/Fixed Deposit/Recurring Deposit/Current/Share account a client holds, by client number or mobile number.") +@RequiredArgsConstructor +public class ClientAllAccountsApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final ApiClientCredentialService apiClientCredentialService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // Same header-precedence list CallerIpTrackingFilter uses elsewhere in this fork, kept local + // rather than depending on that filter bean: it's only conditionally registered + // (fineract.ip-tracking.enabled), and an API-key IP allowlist has to work regardless of whether + // that unrelated feature toggle is on. + private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" }; + + // One UNION ALL, one round trip: SB/FD/RD (m_savings_account, split by deposit_type_enum), + // CD (m_current_account, its own table -- current accounts were never folded into + // m_savings_account here, see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the same + // split), and Share (m_share_account). Every leg filters on client_id, which is indexed on + // all three tables (fksa00000000000001, idx_ca_client_id, m_share_account_ibfk_9) -- an + // index-only lookup per leg, not a scan. + private static final String ACCOUNTS_SQL = """ + SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id, + sa.status_enum AS status_enum, sa.currency_code, sa.product_id, + p.name AS product_name, p.short_name AS short_product_name, + sa.account_balance_derived AS balance, COALESCE(sa.on_hold_funds_derived, 0) AS on_hold, + NULL::bigint AS approved_shares, NULL::bigint AS pending_shares, NULL::numeric(10,2) AS unit_price, + sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date + FROM m_savings_account sa + JOIN m_savings_product p ON p.id = sa.product_id + WHERE sa.client_id = ? + + UNION ALL + + SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id, + ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name, + ca.account_balance_derived, COALESCE(ca.on_hold_funds_derived, 0), + NULL::bigint, NULL::bigint, NULL::numeric(10,2), + ca.submittedon_date, ca.activatedon_date + FROM m_current_account ca + JOIN m_current_product cp ON cp.id = ca.product_id + WHERE ca.client_id = ? + + UNION ALL + + SELECT CAST(NULL AS smallint) AS deposit_type, shr.id, shr.account_no, shr.external_id, + shr.status_enum, shr.currency_code, shr.product_id, sp.name, sp.short_name, + NULL::numeric(19,6), NULL::numeric(19,6), + shr.total_approved_shares, shr.total_pending_shares, sp.unit_price, + shr.submitted_date, shr.activated_date + FROM m_share_account shr + JOIN m_share_product sp ON sp.id = shr.product_id + WHERE shr.client_id = ? + """; + + @GET + @Path("/{identifier}") + @Produces(MediaType.APPLICATION_JSON) + public String retrieveByIdentifier(@PathParam("identifier") final String identifier, + @HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) { + // Dual-mode: a header present means a third-party caller, checked against m_api_client and + // never touching Fineract's own login. No header means an ordinary logged-in user -- exactly + // the same authenticatedUser() gate every other resource in this codebase uses. The two are + // mutually exclusive on purpose: a request carrying an API key is never also allowed to fall + // back to "well, maybe they're logged in too" -- that would let a stolen key ride along on a + // legitimate session's cookies/headers. + if (StringUtils.isNotBlank(apiKey)) { + apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest)); + } else { + context.authenticatedUser(); + } + + final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant(); + final Map client = resolveClient(identifier, tenant); + final Long clientId = ((Number) client.get("id")).longValue(); + + final List> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId); + + final Map totalsByCurrency = new LinkedHashMap<>(); + final Map countByType = new LinkedHashMap<>(); + for (final Map row : rows) { + final String accountType = (String) row.get("accountType"); + countByType.merge(accountType, 1, Integer::sum); + + final Object valueObj = row.get("currentValue"); + if (valueObj instanceof BigDecimal value) { + final String currency = (String) row.get("currencyCode"); + final BigDecimal running = (BigDecimal) totalsByCurrency.getOrDefault(currency, BigDecimal.ZERO); + totalsByCurrency.put(currency, running.add(value)); + } + } + + final JsonObject res = new JsonObject(); + // Which bank this response came from -- not decorative. One API key now authenticates + // against every tenant (see ApiClientCredentialService), so the ONLY thing that decides + // whose data comes back is the Fineract-Platform-TenantId header the caller sent. Echoing it + // (plus the bank's display name) back in the body means a caller debugging "why did I get + // client X's data instead of client Y's" can tell from the response alone, without having to + // go back and re-check what header value they actually sent. + res.addProperty("tenantId", tenant.getTenantIdentifier()); + res.addProperty("tenantName", tenant.getName()); + res.addProperty("searchedIdentifier", identifier); + res.addProperty("clientId", clientId); + res.addProperty("matchedBy", (String) client.get("matched_by")); + res.addProperty("clientNo", (String) client.get("account_no")); + res.addProperty("clientName", (String) client.get("display_name")); + res.addProperty("mobileNo", (String) client.get("mobile_no")); + res.addProperty("officeId", ((Number) client.get("office_id")).longValue()); + res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue()); + res.addProperty("totalAccounts", rows.size()); + res.add("accountsByType", gson.toJsonTree(countByType)); + res.add("totalCurrentValueByCurrency", gson.toJsonTree(totalsByCurrency)); + res.add("accounts", gson.toJsonTree(rows)); + return gson.toJson(res); + } + + /** + * Resolves the path identifier as a client number first, then a mobile number -- two lookups in + * the worst case, one in the common case, both on unique indexed columns + * ({@code m_client_account_no_key}, {@code m_client_mobile_no_key} / {@code idx_client_mobile_no}), + * so neither costs more than the single index-scan lookup this whole endpoint is built around. + * Client number is tried first because it's the identifier a teller normally has on the slip in + * front of them; falling through to mobile number covers the third-party case where that's the + * only thing the caller was given. A value that happens to collide across both columns for two + * different clients is not handled specially -- account numbers and mobile numbers use disjoint + * formats in this system, so this cannot occur in practice. + * + * {@code tenant} is only for the not-found message: with one API key now valid across every bank + * (see {@link ApiClientCredentialService}), a lookup that finds nothing is as often "right + * identifier, wrong bank" (a stale or mistyped {@code Fineract-Platform-TenantId} header) as it is + * a genuinely unknown client -- naming the bank actually searched turns that from a guessing game + * into something the caller can act on immediately. + */ + private Map resolveClient(final String identifier, final FineractPlatformTenant tenant) { + final List> byAccountNo = jdbcTemplate.queryForList(""" + SELECT id, account_no, display_name, mobile_no, office_id, status_enum + FROM m_client WHERE account_no = ? + """, identifier); + if (!byAccountNo.isEmpty()) { + final Map client = new LinkedHashMap<>(byAccountNo.get(0)); + client.put("matched_by", "CLIENT_NO"); + return client; + } + + final List> byMobileNo = jdbcTemplate.queryForList(""" + SELECT id, account_no, display_name, mobile_no, office_id, status_enum + FROM m_client WHERE mobile_no = ? + """, identifier); + if (!byMobileNo.isEmpty()) { + final Map client = new LinkedHashMap<>(byMobileNo.get(0)); + client.put("matched_by", "MOBILE_NO"); + return client; + } + + throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '" + + tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the " + + "Fineract-Platform-TenantId header -- this identifier may belong to a different bank."); + } + + private String resolveClientIp(final HttpServletRequest request) { + for (final String header : IP_HEADER_CANDIDATES) { + final String value = request.getHeader(header); + if (StringUtils.isNotBlank(value)) { + // X-Forwarded-For can carry a chain ("client, proxy1, proxy2") -- the first entry is + // the original caller, which is what an allowlist entry is written against. + return value.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } + + /** + * Maps one UNION leg row into a flat, display-ready shape -- category label, human status, and a + * single {@code currentValue} figure so the caller never has to know that a deposit's value lives + * in one column and a share account's lives in another (approved shares × unit price). + */ + private static final class AccountRowMapper implements RowMapper> { + + @Override + public Map mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Integer depositType = JdbcSupport.getInteger(rs, "deposit_type"); + final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum"); + final BigDecimal balance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "balance"); + final BigDecimal onHold = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold"); + final Long approvedShares = JdbcSupport.getLong(rs, "approved_shares"); + final Long pendingShares = JdbcSupport.getLong(rs, "pending_shares"); + final BigDecimal unitPrice = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "unit_price"); + + final boolean isShare = depositType == null; + final String accountType = isShare ? "Share Account" : accountTypeFor(depositType); + + final Map row = new LinkedHashMap<>(); + row.put("accountType", accountType); + row.put("accountId", rs.getLong("account_id")); + row.put("accountNo", rs.getString("account_no")); + row.put("externalId", rs.getString("external_id")); + row.put("productId", rs.getLong("product_id")); + row.put("productName", rs.getString("product_name")); + row.put("shortProductName", rs.getString("short_product_name")); + row.put("currencyCode", rs.getString("currency_code")); + // .toString(), not the raw LocalDate: this Gson instance has no java.time adapter + // registered, so it falls back to reflecting LocalDate's private fields -- which the JVM + // module system blocks outright (java.base does not open java.time to reflection from an + // unnamed module), turning every response into a 500. A plain ISO-8601 string needs no + // adapter and is what every other date field in this API already returns. + row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date"))); + row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date"))); + + if (isShare) { + final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum); + row.put("status", status); + row.put("approvedShares", approvedShares); + row.put("pendingShares", pendingShares); + row.put("unitPrice", unitPrice); + final BigDecimal shareValue = (approvedShares != null && unitPrice != null) + ? unitPrice.multiply(BigDecimal.valueOf(approvedShares)) + : null; + row.put("currentValue", shareValue); + } else { + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + row.put("status", status); + row.put("balance", balance); + row.put("onHoldFunds", onHold); + BigDecimal available = balance; + if (available != null && onHold != null) { + available = available.subtract(onHold); + } + row.put("availableBalance", available); + row.put("currentValue", balance); + } + return row; + } + + private String accountTypeFor(final int depositType) { + return switch (depositType) { + case 100 -> "Savings Account"; + case 200 -> "Fixed Deposit"; + case 300 -> "Recurring Deposit"; + case 400 -> "Current Account"; + default -> "Other"; + }; + } + + private String toIsoDateString(final LocalDate date) { + return date == null ? null : date.toString(); + } + } +}