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 index e7c7d6c37..67b91199b 100644 --- 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 @@ -32,7 +32,6 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; @@ -48,28 +47,44 @@ import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredential 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.dao.EmptyResultDataAccessException; 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. + * A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share + * accounts are out of scope for this endpoint, see below), 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. + * + * Deliberately narrow on purpose: + * + * + * Branch details (name, code, address, pincode, IFSC, MICR -- see {@code m_office_profile}, migrations + * 0511-0513/0538) and bank details (name, SWIFT/BIC, registered address, contact -- see + * {@code m_institution_profile}) are included on every response: a caller resolving "which branch/bank + * is this client's account actually at" (for a passbook header, a cheque book request, a KYC document) + * would otherwise need two more round trips, to {@code /v1/branchprofile/{officeId}} and + * {@code /v1/institutionprofile}. * * 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. + * caller doesn't need. 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 @@ -81,7 +96,7 @@ import org.springframework.stereotype.Component; */ @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.") +@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of a client's ACTIVE Savings/Fixed Deposit/Recurring Deposit/Current accounts (no balances, no Share accounts) plus their branch details, by client number or mobile number.") @RequiredArgsConstructor public class ClientAllAccountsApiResource { @@ -96,44 +111,56 @@ public class ClientAllAccountsApiResource { // 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 + // ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses + // the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the + // same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD + // (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table -- + // current accounts were never folded into m_savings_account here). Every leg filters on + // client_id, which is indexed on both tables (fksa00000000000001, idx_ca_client_id) -- 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 = ? + WHERE sa.client_id = ? AND sa.status_enum = 300 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 = ? + WHERE ca.client_id = ? AND ca.status_enum = 300 + """; - UNION ALL + // Branch details for the client's own office -- name, code, address, pincode, IFSC, MICR -- so a + // caller doesn't need a second round trip to /v1/branchprofile/{officeId} just to print a passbook + // header or route a cheque book request. LEFT JOIN: an office with no m_office_profile row yet + // (see BranchProfileApiResource) is a valid, ordinary state, not an error -- the profile fields + // just come back null. + private static final String BRANCH_SQL = """ + SELECT o.id AS office_id, o.name AS office_name, p.branch_code, p.branch_type, p.address, + p.pincode, p.ifsc_code, p.micr_code, p.contact_phone, p.contact_email + FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id + WHERE o.id = ? + """; - 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 = ? + // The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row, + // id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a + // payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's + // IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative + // block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and + // the created/updated audit columns, which are an internal concern of the Bank Profile screen, not + // this endpoint's caller. + private static final String BANK_SQL = """ + SELECT bank_name, legal_name, short_name, bank_code, swift_bic, + address_line1, address_line2, city, district, state, pincode, + contact_phone, contact_email, customer_care_number, website + FROM m_institution_profile WHERE id = 1 """; @GET @@ -157,22 +184,18 @@ public class ClientAllAccountsApiResource { 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 List> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), 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 Long officeId = ((Number) client.get("office_id")).longValue(); + final Map branch = resolveBranch(officeId); + final Map bank = resolveBank(); + 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 @@ -188,15 +211,71 @@ public class ClientAllAccountsApiResource { 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("officeId", officeId); res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue()); + res.add("branch", gson.toJsonTree(branch)); + res.add("bank", gson.toJsonTree(bank)); 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); } + /** + * An office with no {@code m_office_profile} row yet (see + * {@code org.apache.fineract.portfolio.orgsetup.api.BranchProfileApiResource}) is a valid, ordinary + * state -- every field beyond {@code officeId}/{@code officeName} just comes back null rather than + * this call failing or being skipped. + */ + private Map resolveBranch(final Long officeId) { + final Map row = jdbcTemplate.queryForMap(BRANCH_SQL, officeId); + final Map branch = new LinkedHashMap<>(); + branch.put("officeId", officeId); + branch.put("officeName", row.get("office_name")); + branch.put("branchCode", row.get("branch_code")); + branch.put("branchType", row.get("branch_type")); + branch.put("address", row.get("address")); + branch.put("pincode", row.get("pincode")); + branch.put("ifscCode", row.get("ifsc_code")); + branch.put("micrCode", row.get("micr_code")); + branch.put("contactPhone", row.get("contact_phone")); + branch.put("contactEmail", row.get("contact_email")); + return branch; + } + + /** + * The bank running this tenant is a singleton row (id=1) that only gets created the first time + * someone opens the Bank Profile screen (see {@code InstitutionProfileApiResource#ensureRowExists} + * -- this endpoint never creates it itself, it only reads). A tenant where nobody has ever opened + * that screen yet is a valid, ordinary state, not an error -- {@code bank} just comes back {@code + * null} in the response rather than this call failing. + */ + private Map resolveBank() { + final Map row; + try { + row = jdbcTemplate.queryForMap(BANK_SQL); + } catch (final EmptyResultDataAccessException e) { + return null; + } + final Map bank = new LinkedHashMap<>(); + bank.put("bankName", row.get("bank_name")); + bank.put("legalName", row.get("legal_name")); + bank.put("shortName", row.get("short_name")); + bank.put("bankCode", row.get("bank_code")); + bank.put("swiftBic", row.get("swift_bic")); + bank.put("addressLine1", row.get("address_line1")); + bank.put("addressLine2", row.get("address_line2")); + bank.put("city", row.get("city")); + bank.put("district", row.get("district")); + bank.put("state", row.get("state")); + bank.put("pincode", row.get("pincode")); + bank.put("contactPhone", row.get("contact_phone")); + bank.put("contactEmail", row.get("contact_email")); + bank.put("customerCareNumber", row.get("customer_care_number")); + bank.put("website", row.get("website")); + return bank; + } + /** * 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 @@ -275,27 +354,19 @@ public class ClientAllAccountsApiResource { } /** - * 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). + * Maps one UNION leg row into a flat, display-ready shape -- category label and human status only. + * No balance figure of any kind: this is an account listing, not a balance enquiry (see the class + * Javadoc). */ 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 int depositType = rs.getInt("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("accountType", accountTypeFor(depositType)); row.put("accountId", rs.getLong("account_id")); row.put("accountNo", rs.getString("account_no")); row.put("externalId", rs.getString("external_id")); @@ -311,28 +382,8 @@ public class ClientAllAccountsApiResource { 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); - } + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + row.put("status", status); return row; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java index 286c3d817..55f7dd7e1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java @@ -98,6 +98,7 @@ public class BranchProfileApiResource { final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root); final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root); final String address = this.fromJsonHelper.extractStringNamed("address", root); + final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); @@ -124,22 +125,22 @@ public class BranchProfileApiResource { if (existing != null && existing > 0) { jdbcTemplate.update(""" UPDATE m_office_profile - SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?, + SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?, working_hours_start = ?, working_hours_end = ?, working_days = ?, ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, updated_by_username = ?, updated_at = ? WHERE office_id = ? - """, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, + """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), Timestamp.from(Instant.now()), officeId); } else { jdbcTemplate.update(""" INSERT INTO m_office_profile - (office_id, branch_code, branch_type, address, contact_phone, contact_email, + (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email, working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, latitude, longitude, updated_by_username, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), Timestamp.from(Instant.now())); } @@ -150,7 +151,7 @@ public class BranchProfileApiResource { private String baseSql() { return """ SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date, - p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email, + p.branch_code, p.branch_type, p.address, p.pincode, p.contact_phone, p.contact_email, p.working_hours_start, p.working_hours_end, p.working_days, p.ifsc_code, p.micr_code, p.latitude, p.longitude, p.updated_by_username, p.updated_at @@ -169,6 +170,7 @@ public class BranchProfileApiResource { out.put("branchCode", row.get("branch_code")); out.put("branchType", row.get("branch_type")); out.put("address", row.get("address")); + out.put("pincode", row.get("pincode")); out.put("contactPhone", row.get("contact_phone")); out.put("contactEmail", row.get("contact_email")); out.put("workingHoursStart", row.get("working_hours_start")); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 06323958f..27d34f83c 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -64,4 +64,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml new file mode 100644 index 000000000..eb5ca26d2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + +