feat(client): add fast client-accounts lookup by client number or mobile number
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
GET /v1/clientaccounts/{identifier} -- a single client's full deposit + share
picture (Savings, Fixed Deposit, Recurring Deposit, Current, Share), looked
up by client number or mobile number rather than the internal client id, so
a teller with only the slip in front of them (or a third-party caller with
only a mobile number) needs no separate client-search step first. Meant to
answer "is there a problem here" (e.g. a share purchase that would exceed a
product's issuable shares) BEFORE a manager approves something, not after.
Deliberately its own lightweight resource rather than a reuse of the
existing GET /clients/{clientId}/accounts (AccountSummaryCollectionData):
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. One UNION ALL query (SB/FD/RD from
m_savings_account split by deposit_type_enum, CD from its own
m_current_account table, Share from m_share_account), each leg filtering on
an indexed client_id -- fewer joins and one round trip is what actually
makes this fast, not a different query engine.
Identifier resolution tries m_client.account_no first (what a teller has),
then mobile_no (what a third-party caller more often has) -- both unique
indexed columns, so neither path costs more than a single index lookup.
Reachable two ways: an ordinary logged-in Fineract user exactly as with any
other endpoint, or a third-party caller via the new X-Api-Key mechanism
(see the ApiClientCredentialService commit) -- one key now valid across
every tenant, so the response echoes back tenantId/tenantName and the
identifier that was searched, and a not-found error names the bank actually
searched. Without that, "no client found" from the wrong bank (a stale or
mistyped Fineract-Platform-TenantId header) was indistinguishable from a
genuinely unknown client.
Every date field is serialized as a plain ISO string, never a raw
java.time.LocalDate: this Gson instance has no java.time adapter registered,
and reflectively touching LocalDate's private fields is blocked outright by
the JVM module system (java.base does not open java.time to an unnamed
module) -- passing one through untouched turns the whole response into a
500.
This commit is contained in:
+330
@@ -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<String, Object> client = resolveClient(identifier, tenant);
|
||||||
|
final Long clientId = ((Number) client.get("id")).longValue();
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId);
|
||||||
|
|
||||||
|
final Map<String, Object> totalsByCurrency = new LinkedHashMap<>();
|
||||||
|
final Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||||
|
for (final Map<String, Object> 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<String, Object> resolveClient(final String identifier, final FineractPlatformTenant tenant) {
|
||||||
|
final List<Map<String, Object>> 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<String, Object> client = new LinkedHashMap<>(byAccountNo.get(0));
|
||||||
|
client.put("matched_by", "CLIENT_NO");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> 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<String, Object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user