From f746a1fb2c26f519b2920545ad80b10399f13f23 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 28 Aug 2026 14:36:59 +0530 Subject: [PATCH] feat(cashier,accounting): denomination-aware vault controls, Bank/Branch Master, Fund Transfer request type Closes the gap where a vault could hand out more cash (in total or in a specific note/coin) than it actually held: CashTransferApiResource now validates against the branch day's opening denomination breakdown plus today's settlements/transfers, and blocks entirely when no business day is open. BranchDayRegisterApiResource gains a real denomination breakdown per open/close leg (migration 0510), carried forward automatically day to day, and its book-cash formula no longer double-counts vault-to-cashier transfers as new cash in. CashReportApiResource adds a vault-position report (the teller/vault analogue of cashier-position) and fixes an off-by-one date comparison that excluded same-day transfers from a vault's anchor position. Institution/Branch Master: new InstitutionProfileApiResource and BranchProfileApiResource (migrations 0511-0513) give the bank a real "Bank Master" profile (identity, regulatory details, address, SWIFT/BIC) and each office its own branch-level fields (code, IFSC/MICR, working hours, geo-location) as side tables on m_office rather than touching the stock Fineract office entity. ClearingGlConfigApiResource exposes the previously config-only-via-SQL m_clearing_gl_config mapping (CTS/NACH/RTGS suspense accounts) as a proper admin API. ClerkDisbursementRequestApiResource now accepts a FUND_TRANSFER request type alongside DISBURSEMENT/COLLECTION/BANK_TRANSFER, and migration 0514 grants the teller-facing roles READ_CLIENTNOMINEE, which they were missing entirely. Co-Authored-By: Claude Sonnet 5 --- .../api/BranchDayRegisterApiResource.java | 161 +++++++++- .../cashreport/api/CashReportApiResource.java | 124 ++++++++ .../api/CashTransferApiResource.java | 123 ++++++++ .../api/ClearingGlConfigApiResource.java | 133 +++++++++ .../ClerkDisbursementRequestApiResource.java | 2 +- .../api/BranchProfileApiResource.java | 186 ++++++++++++ .../api/InstitutionProfileApiResource.java | 274 ++++++++++++++++++ .../tenant/final-changelog-tenant.xml | 5 + ...ranch_day_register_denomination_detail.xml | 49 ++++ .../0511_institution_and_branch_profile.xml | 135 +++++++++ ...and_institution_and_branch_identifiers.xml | 59 ++++ ...3_institution_profile_full_bank_master.xml | 78 +++++ ..._grant_teller_roles_read_clientnominee.xml | 28 ++ 13 files changed, 1351 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index ae56cd0b9..4810abbac 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -207,6 +207,7 @@ public class BranchDayRegisterApiResource { final LocalDate businessDate = extractDate(root, "businessDate", LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); + final List> denominations = extractDenominations(root, "denominations"); // A branch runs one day at a time. Opening a second while the first is still open is how a // day gets stranded, so the previous one has to be closed first. @@ -229,9 +230,17 @@ public class BranchDayRegisterApiResource { "Business day " + businessDate + " has already been opened and closed for this branch."); } - // Yesterday's closing is today's opening unless the caller states otherwise, so the carried - // figure does not have to be retyped and cannot drift. - final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate); + // A denomination breakdown given by the caller is the true source of the opening figure — + // its own total overrides a plain openingCash, the same way physically counting the drawer + // overrides whatever figure was expected. Without one, fall back to yesterday's own closing + // breakdown (if it recorded one) so the note/coin stock stays traceable day to day instead + // of resetting to "unknown" the moment nobody re-enters it; only when neither exists does + // this day open with a lump sum and no denomination detail at all. + final List> effectiveDenominations = !denominations.isEmpty() ? denominations + : (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of()); + final BigDecimal denominationTotal = sumDenominations(effectiveDenominations); + final BigDecimal carried = denominationTotal != null ? denominationTotal + : (openingCash != null ? openingCash : lastClosingCash(officeId, businessDate)); final Long id = jdbcTemplate.queryForObject(""" INSERT INTO m_branch_day_register @@ -242,6 +251,8 @@ public class BranchDayRegisterApiResource { Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), carried); + saveDenominationDetail(id, "OPENING", effectiveDenominations); + return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(), "openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN")); } @@ -262,7 +273,12 @@ public class BranchDayRegisterApiResource { throw new PlatformDataIntegrityException("error.msg.branchday.date.required", "Which business day is being closed?"); } - final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); + final List> denominations = extractDenominations(root, "denominations"); + final BigDecimal denominationTotal = sumDenominations(denominations); + // A physical count at close is the same override relationship as at open: what was actually + // counted note-by-note wins over a typed-in total. + final BigDecimal closingCash = denominationTotal != null ? denominationTotal + : this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root); @@ -300,6 +316,9 @@ public class BranchDayRegisterApiResource { bookCash, difference, isLate, isLate ? forceReason : null, remarks, officeId, Date.valueOf(businessDate)); + final Long registerId = ((Number) found.get(0).get("id")).longValue(); + saveDenominationDetail(registerId, "CLOSING", denominations); + final Map res = new LinkedHashMap<>(); res.put("officeId", officeId); res.put("businessDate", businessDate.toString()); @@ -311,8 +330,133 @@ public class BranchDayRegisterApiResource { return gson.toJson(res); } + // ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ──── + + /** + * The opening and closing denomination breakdown for one business day, if one was ever + * recorded — a day opened or closed with a plain typed-in amount and no breakdown returns an + * empty list for that leg rather than an error, since not every day is guaranteed to carry one. + */ + @GET + @Path("denominations") + public String denominations(@QueryParam("officeId") final Long officeIdParam, + @QueryParam("businessDate") final String businessDateParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank()) + ? LocalDate.parse(businessDateParam) + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final List registerIds = jdbcTemplate.queryForList( + "SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + Long.class, officeId, Date.valueOf(businessDate)); + if (registerIds.isEmpty()) { + return gson.toJson(Map.of("officeId", officeId, "businessDate", businessDate.toString(), + "opening", List.of(), "closing", List.of())); + } + + final Long registerId = registerIds.get(0); + final List> rows = jdbcTemplate.queryForList(""" + SELECT leg, denomination_id, denomination_value, count, total_amount + FROM m_branch_day_register_detail + WHERE register_id = ? + ORDER BY denomination_value DESC + """, registerId); + + final List> opening = new ArrayList<>(); + final List> closing = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("denominationId", r.get("denomination_id")); + m.put("denominationValue", r.get("denomination_value")); + m.put("count", r.get("count")); + m.put("totalAmount", r.get("total_amount")); + if ("OPENING".equals(r.get("leg"))) { + opening.add(m); + } else { + closing.add(m); + } + } + return gson.toJson(Map.of("officeId", officeId, "businessDate", businessDate.toString(), + "opening", opening, "closing", closing)); + } + // ── Helpers ─────────────────────────────────────────────────────────────── + /** Reads a denominations[] array of {denominationId, denominationValue, count} into plain maps. */ + private List> extractDenominations(final com.google.gson.JsonElement root, final String field) { + if (!this.fromJsonHelper.parameterExists(field, root)) { + return List.of(); + } + final com.google.gson.JsonArray array = this.fromJsonHelper.extractJsonArrayNamed(field, root); + if (array == null) { + return List.of(); + } + final List> out = new ArrayList<>(); + for (final com.google.gson.JsonElement el : array) { + final com.google.gson.JsonObject obj = el.getAsJsonObject(); + final int count = obj.has("count") && !obj.get("count").isJsonNull() ? obj.get("count").getAsInt() : 0; + if (count <= 0) { + continue; + } + final Map row = new LinkedHashMap<>(); + row.put("denominationId", obj.has("denominationId") && !obj.get("denominationId").isJsonNull() + ? obj.get("denominationId").getAsLong() : null); + row.put("denominationValue", obj.has("denominationValue") && !obj.get("denominationValue").isJsonNull() + ? obj.get("denominationValue").getAsInt() : 0); + row.put("count", count); + out.add(row); + } + return out; + } + + private BigDecimal sumDenominations(final List> denominations) { + if (denominations == null || denominations.isEmpty()) { + return null; + } + BigDecimal total = BigDecimal.ZERO; + for (final Map row : denominations) { + final int value = (int) row.get("denominationValue"); + final int count = (int) row.get("count"); + total = total.add(BigDecimal.valueOf((long) value * count)); + } + return total; + } + + private void saveDenominationDetail(final Long registerId, final String leg, final List> denominations) { + if (denominations == null || denominations.isEmpty()) { + return; + } + for (final Map row : denominations) { + final int value = (int) row.get("denominationValue"); + final int count = (int) row.get("count"); + jdbcTemplate.update(""" + INSERT INTO m_branch_day_register_detail + (register_id, leg, denomination_id, denomination_value, count, total_amount) + VALUES (?,?,?,?,?,?) + """, registerId, leg, row.get("denominationId"), value, count, + BigDecimal.valueOf((long) value * count)); + } + } + + /** Yesterday's own closing breakdown, if it recorded one — the denomination-level analogue of lastClosingCash. */ + private List> lastClosingDenominations(final Long officeId, final LocalDate before) { + final List registerIds = jdbcTemplate.queryForList(""" + SELECT id FROM m_branch_day_register + WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL + ORDER BY business_date DESC LIMIT 1 + """, Long.class, officeId, Date.valueOf(before)); + if (registerIds.isEmpty()) { + return List.of(); + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT denomination_id AS "denominationId", denomination_value AS "denominationValue", count + FROM m_branch_day_register_detail + WHERE register_id = ? AND leg = 'CLOSING' + """, registerIds.get(0)); + return rows; + } + private List> mapDays(final List> rows) { final List> out = new ArrayList<>(); for (final Map r : rows) { @@ -378,6 +522,13 @@ public class BranchDayRegisterApiResource { * approved cash deposits less what went out as approved cash withdrawals. Deliberately derived * here and then stored on the row, so the figure the day was closed against survives any later * reversal. + * + * Vault-to-cashier transfers and cashier-to-vault settlements are shown for information but are + * NOT part of the total: they move cash between the branch's own vault and its own cashiers, so + * the branch's overall physical cash is unchanged either way. Adding the transfer and + * subtracting the settlement (an earlier version of this method did both) double-counts the same + * rupee — once as the day's opening figure, again as "new" cash arriving via the transfer — and + * manufactures a phantom shortage equal to whatever was handed to a cashier that day. */ private Map bookBreakdown(final Long officeId, final LocalDate businessDate) { final Date date = Date.valueOf(businessDate); @@ -416,7 +567,7 @@ public class BranchDayRegisterApiResource { WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false """, BigDecimal.class, date)); - final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut); + final BigDecimal expected = opening.add(deposits).subtract(withdrawals); final Map out = new LinkedHashMap<>(); out.put("openingCash", opening); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 95e444476..ec99ad3c3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -533,6 +533,130 @@ public class CashReportApiResource { return gson.toJson(buildPositionForCashier(cashierId, dateFilter)); } + /** + * GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd + * Denomination-wise stock a vault (teller) is currently holding — "how much of what does the + * bank have", as opposed to cashier-position which answers the same question for a cashier. + * Anchored on the most recent business day (on or before asOfDate) whose branch day register + * recorded an OPENING denomination breakdown, then walked forward by everything settled back + * into this vault and handed out of it since. A vault whose opening days never recorded a + * breakdown (plain lump-sum opens only) has nothing to report per denomination — the response + * says so explicitly rather than silently returning zeros that look like an empty vault. + */ + @GET + @Path("vault-position") + public String vaultPosition(@QueryParam("tellerId") final Long tellerId, + @QueryParam("asOfDate") final String asOfDate) { + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + + if (tellerId == null) { + final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class); + final List> all = new ArrayList<>(); + for (final Long id : tellerIds) { + all.add(buildPositionForVault(id, dateFilter)); + } + return gson.toJson(all); + } + return gson.toJson(buildPositionForVault(tellerId, dateFilter)); + } + + private Map buildPositionForVault(final Long tellerId, final String dateFilter) { + final Map result = new LinkedHashMap<>(); + result.put("tellerId", tellerId); + result.put("asOfDate", dateFilter); + + String tellerName = null; + Long officeId = null; + try { + final Map teller = jdbcTemplate.queryForMap( + "SELECT name, office_id FROM m_tellers WHERE id = ?", tellerId); + tellerName = (String) teller.get("name"); + officeId = ((Number) teller.get("office_id")).longValue(); + } catch (Exception ignored) { + // intentionally ignored + } + result.put("tellerName", tellerName); + + if (officeId == null) { + result.put("hasBreakdown", false); + result.put("totalAmount", BigDecimal.ZERO); + result.put("denominations", new ArrayList<>()); + return result; + } + + // The anchor: the most recent register on or before asOfDate that actually recorded an + // opening breakdown. Its breakdown already reflects every prior day's carry-forward (see + // BranchDayRegisterApiResource#lastClosingDenominations), so only movements from that one + // anchor date forward need to be added on top of it. + final List> anchorRows = jdbcTemplate.queryForList(""" + SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r + WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) + AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d + WHERE d.register_id = r.id AND d.leg = 'OPENING') + ORDER BY r.business_date DESC LIMIT 1 + """, officeId, dateFilter); + + if (anchorRows.isEmpty()) { + result.put("hasBreakdown", false); + result.put("totalAmount", BigDecimal.ZERO); + result.put("denominations", new ArrayList<>()); + return result; + } + + final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue(); + final String anchorDate = anchorRows.get(0).get("business_date").toString(); + + final Map balance = new LinkedHashMap<>(); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count + FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id + WHERE d.register_id = ? AND d.leg = 'OPENING' + """, DENOM_ROW, registerId), 1); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count + FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id + LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id + WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) + AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE) + GROUP BY csd.denomination_id, csd.denomination_value, dm.kind + """, DENOM_ROW, tellerId, anchorDate, dateFilter), 1); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count + FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id + LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id + WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) + AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE) + GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind + """, DENOM_ROW, tellerId, anchorDate, dateFilter), -1); + + final List> rows = new ArrayList<>(); + balance.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) + .forEach(e -> { + final Map row = new LinkedHashMap<>(); + row.put("denominationId", e.getKey()); + row.put("denominationValue", (int) e.getValue()[0]); + row.put("kind", e.getValue()[1] == 1L ? "coin" : "note"); + row.put("count", e.getValue()[2]); + row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2])); + rows.add(row); + }); + + final BigDecimal totalAmount = rows.stream() + .map(r -> (BigDecimal) r.get("amount")) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + result.put("hasBreakdown", true); + result.put("anchorDate", anchorDate); + result.put("totalAmount", totalAmount); + result.put("denominations", rows); + return result; + } + /** * GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD * Returns denomination-wise cash received by the cashier from vault on the given date diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 615b0623e..07a16f84e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -98,6 +98,8 @@ public class CashTransferApiResource { "At least one denomination with a positive count is required."); } + validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); + CashTransfer header = new CashTransfer(); header.setTransferDate(transferDate); header.setFromParty(fromParty); @@ -264,6 +266,127 @@ public class CashTransferApiResource { return total; } + /** + * A vault can only hand out cash it is actually holding — in total, and note-by-note. The + * branch's business day carries an opening_cash figure forward each day (see + * BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is + * that opening figure, plus anything a cashier has already settled back into this same vault + * today, minus whatever this vault has already handed out to any cashier today. Reversed + * transfers/settlements don't count. + * + * No open business day for the vault's office means there is no opening_cash to check against, so + * the transfer is blocked outright rather than silently allowed against an unknown balance. + */ + private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount, + final JsonArray denominations, final LocalDate transferDate) { + final Long officeId; + try { + officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId); + } catch (Exception e) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found", + "Vault/teller " + fromTellerId + " was not found."); + } + + final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate); + + final List registerIds = jdbcTemplate.query(""" + SELECT id FROM m_branch_day_register + WHERE office_id = ? AND business_date = ? AND closed_at IS NULL + """, (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate); + if (registerIds.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open", + "The business day for this branch is not open for " + businessDate + + ". Open the business day before allocating cash to a cashier."); + } + final Long registerId = registerIds.get(0); + + final BigDecimal openingCash = jdbcTemplate.queryForObject( + "SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId); + + final BigDecimal handedOutToday = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer + WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, fromTellerId, sqlBusinessDate); + + final BigDecimal settledBackToday = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement + WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, fromTellerId, sqlBusinessDate); + + final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday); + if (requestedAmount.compareTo(available) > 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash", + "Insufficient vault cash: available " + available + ", requested " + requestedAmount + "."); + } + + validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate); + } + + /** + * The total-amount check above can pass while a specific note/coin still runs short — a vault + * holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the + * total is well within its balance. This checks each requested denomination against what the + * vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's + * settlements-in minus today's transfers-out actually holds of that specific denomination. + * + * A day opened with a plain lump sum and no breakdown recorded has nothing to check a + * denomination against, so this is skipped rather than blocking every transfer on a day that + * simply never captured one. + */ + private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId, + final JsonArray denominations, final java.sql.Date businessDate) { + final List openingRows = jdbcTemplate.query(""" + SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail + WHERE register_id = ? AND leg = 'OPENING' + """, (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId); + if (openingRows.isEmpty()) { + return; + } + + final Map available = new java.util.HashMap<>(); + for (final Object[] row : openingRows) { + available.merge((Long) row[0], (Integer) row[2], Integer::sum); + } + + final List settledRows = jdbcTemplate.query(""" + SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd + JOIN m_cash_settlement cs ON cs.id = csd.settlement_id + WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false + GROUP BY csd.denomination_id + """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); + for (final Object[] row : settledRows) { + available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum); + } + + final List handedOutRows = jdbcTemplate.query(""" + SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd + JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id + WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false + GROUP BY ctd.denomination_id + """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); + for (final Object[] row : handedOutRows) { + available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum); + } + + for (final JsonElement el : denominations) { + final JsonObject item = el.getAsJsonObject(); + final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0; + if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) { + continue; + } + final long denominationId = item.get("denominationId").getAsLong(); + final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() + ? item.get("denominationValue").getAsInt() : 0; + final int availableCount = available.getOrDefault(denominationId, 0); + if (count > availableCount) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination", + "Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount + + ", requested " + count + "."); + } + } + } + private boolean hasPositiveCount(JsonArray items) { for (JsonElement el : items) { JsonObject item = el.getAsJsonObject(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java new file mode 100644 index 000000000..f02f044af --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java @@ -0,0 +1,133 @@ +/** + * 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.clearing.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.ArrayList; +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.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing + * role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are + * fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads + * them and updates which GL account one points at. + */ +@Path("/v1/clearingglconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ClearingGlConfigApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG"); + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code + FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id + ORDER BY c.config_key + """); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + out.add(toResult(row)); + } + return gson.toJson(out); + } + + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG"); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code FROM acc_gl_account + WHERE disabled = false AND manual_journal_entries_allowed = true + ORDER BY name + """); + final List> options = new ArrayList<>(); + for (final Map a : accounts) { + final Map o = new LinkedHashMap<>(); + o.put("id", a.get("id")); + o.put("name", a.get("name")); + o.put("glCode", a.get("gl_code")); + options.add(o); + } + return gson.toJson(Map.of("glAccountOptions", options)); + } + + @PUT + @Path("{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String update(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG"); + + final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?", + Integer.class, configKey); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found", + "Clearing GL config key '" + configKey + "' was not found."); + } + + final var root = this.fromJsonHelper.parse(body); + final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root); + + if (glAccountId != null) { + final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?", + Integer.class, glAccountId); + if (accountExists == null || accountExists == 0) { + throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found", + "GL account " + glAccountId + " was not found."); + } + } + + jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId, + configKey); + + return retrieveAll(); + } + + private Map toResult(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("id", row.get("id")); + out.put("configKey", row.get("config_key")); + out.put("description", row.get("description")); + out.put("glAccountId", row.get("gl_account_id")); + out.put("glAccountName", row.get("gl_account_name")); + out.put("glCode", row.get("gl_code")); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 53913d2da..b2ad10e9f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -109,7 +109,7 @@ public class ClerkDisbursementRequestApiResource { final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root); String requestType = fromJsonHelper.extractStringNamed("requestType", root); if (requestType == null || requestType.isBlank() - || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) { + || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) { requestType = "DISBURSEMENT"; } 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 new file mode 100644 index 000000000..286c3d817 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java @@ -0,0 +1,186 @@ +/** + * 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.orgsetup.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.time.Instant; +import java.sql.Timestamp; +import java.util.ArrayList; +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.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The operational profile behind a branch — code, address, contact, type and working hours — kept as + * a side table on m_office (see migration 0511) rather than added to the core office entity, so this + * stays independent of the stock office create/edit flow. Every office already exists via the + * standard Offices screen; this only adds the real-world fields on top for the offices that have them + * set. An office with no profile row yet is a valid, ordinary state — not an error. + */ +@Path("/v1/branchprofile") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class BranchProfileApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String list(@QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission("BRANCHPROFILE"); + final String where = officeId != null ? "WHERE o.id = ?" : ""; + final List> rows = officeId != null + ? jdbcTemplate.queryForList(baseSql() + where + " ORDER BY o.hierarchy", officeId) + : jdbcTemplate.queryForList(baseSql() + " ORDER BY o.hierarchy"); + + final List> out = new ArrayList<>(); + for (final Map row : rows) { + out.add(toResult(row)); + } + if (officeId != null) { + if (out.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", + "Office " + officeId + " was not found."); + } + return gson.toJson(out.get(0)); + } + return gson.toJson(out); + } + + @PUT + @Path("{officeId}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String update(@PathParam("officeId") final Long officeId, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE"); + + final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", + Integer.class, officeId); + if (officeExists == null || officeExists == 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", + "Office " + officeId + " was not found."); + } + + final var root = this.fromJsonHelper.parse(body); + 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 contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); + final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); + final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); + final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root); + final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root); + final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root); + final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root); + final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root); + final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root); + + if (branchCode != null && !branchCode.isBlank()) { + final Integer dupe = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class, + branchCode, officeId); + if (dupe != null && dupe > 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate", + "Branch code '" + branchCode + "' is already used by another branch."); + } + } + + final AppUser currentUser = this.context.authenticatedUser(); + final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?", + Integer.class, officeId); + if (existing != null && existing > 0) { + jdbcTemplate.update(""" + UPDATE m_office_profile + SET branch_code = ?, branch_type = ?, address = ?, 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, + 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, + 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, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now())); + } + + return list(officeId); + } + + 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.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 + FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id + """; + } + + private Map toResult(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("officeId", row.get("office_id")); + out.put("officeName", row.get("office_name")); + out.put("hierarchy", row.get("hierarchy")); + out.put("parentId", row.get("parent_id")); + final Object opening = row.get("opening_date"); + out.put("openingDate", opening == null ? null : opening.toString()); + out.put("branchCode", row.get("branch_code")); + out.put("branchType", row.get("branch_type")); + out.put("address", row.get("address")); + out.put("contactPhone", row.get("contact_phone")); + out.put("contactEmail", row.get("contact_email")); + out.put("workingHoursStart", row.get("working_hours_start")); + out.put("workingHoursEnd", row.get("working_hours_end")); + out.put("workingDays", row.get("working_days")); + out.put("ifscCode", row.get("ifsc_code")); + out.put("micrCode", row.get("micr_code")); + out.put("latitude", row.get("latitude")); + out.put("longitude", row.get("longitude")); + out.put("updatedByUsername", row.get("updated_by_username")); + final Object updatedAt = row.get("updated_at"); + out.put("updatedAt", updatedAt == null ? null : updatedAt.toString()); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java new file mode 100644 index 000000000..7bcf6fc7d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java @@ -0,0 +1,274 @@ +/** + * 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.orgsetup.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The institution's own "Bank Master" — identity and classification, regulatory details, address + * (registered, with optional head-office/communication overrides), contact, the institution-level + * banking identifier (SWIFT/BIC — IFSC/MICR are per-branch and live on {@link BranchProfileApiResource} + * instead), document header names and terms text, the authorised representative, and audit fields. A + * singleton (always id=1): unlike offices there is only ever one bank running this tenant. + * + * Deliberately does NOT store Business Date, Financial Year, Time Zone, Working Days or Holiday + * Calendar — those already exist as their own configuration elsewhere in the system, and copying + * them here would just create a second, driftable value for the same fact. The currency shown is + * likewise derived live from {@code m_organisation_currency} rather than stored. + */ +@Path("/v1/institutionprofile") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class InstitutionProfileApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String get() { + this.context.authenticatedUser().validateHasReadPermission("INSTITUTIONPROFILE"); + ensureRowExists(); + final Map row = jdbcTemplate.queryForMap(""" + SELECT bank_name, legal_name, short_name, bank_code, institution_code, bank_type, + ownership_type, parent_institution_name, + registration_number, license_number, established_date, status, country, + regulatory_authority, registration_date, license_status, license_expiry_date, + swift_bic, + address_line1, address_line2, city, district, state, pincode, + head_office_same_as_registered, head_office_address, + communication_same_as_registered, communication_address, + contact_phone, contact_email, customer_care_number, grievance_email, website, + pan, tan, gstin, + statement_header_name, passbook_header_name, receipt_header_name, terms_and_conditions, + authorized_person_name, authorized_person_designation, authorized_person_contact, + authorized_person_email, + created_by_username, created_at, activation_date, deactivation_date, + updated_by_username, updated_at + FROM m_institution_profile WHERE id = 1 + """); + return gson.toJson(toResult(row)); + } + + @PUT + @Consumes({ MediaType.APPLICATION_JSON }) + public String update(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE"); + ensureRowExists(); + + final var root = this.fromJsonHelper.parse(body); + final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root); + final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root); + final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root); + final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root); + final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root); + final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root); + final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root); + final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root); + final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root); + final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root); + final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root)); + final String status = this.fromJsonHelper.extractStringNamed("status", root); + final String country = this.fromJsonHelper.extractStringNamed("country", root); + final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root); + final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root)); + final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root); + final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root)); + final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root); + final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root); + final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root); + final String city = this.fromJsonHelper.extractStringNamed("city", root); + final String district = this.fromJsonHelper.extractStringNamed("district", root); + final String state = this.fromJsonHelper.extractStringNamed("state", root); + final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); + final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root) + ? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE; + final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root); + final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root) + ? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE; + final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root); + final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); + final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); + final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root); + final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root); + final String website = this.fromJsonHelper.extractStringNamed("website", root); + final String pan = this.fromJsonHelper.extractStringNamed("pan", root); + final String tan = this.fromJsonHelper.extractStringNamed("tan", root); + final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root); + final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root); + final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root); + final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root); + final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root); + final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root); + final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation", + root); + final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root); + final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root); + + // A status flip is stamped automatically the first time it happens, rather than asking the + // user to also remember to set a date — the previous row's own status is what "the first + // time" is judged against. + final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1", + String.class); + final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus); + final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus); + final LocalDate today = LocalDate.now(); + + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + UPDATE m_institution_profile + SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?, + bank_type = ?, ownership_type = ?, parent_institution_name = ?, + registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?, + regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?, + swift_bic = ?, + address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?, + head_office_same_as_registered = ?, head_office_address = ?, + communication_same_as_registered = ?, communication_address = ?, + contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?, + website = ?, pan = ?, tan = ?, gstin = ?, + statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?, + terms_and_conditions = ?, + authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?, + authorized_person_email = ?, + activation_date = CASE WHEN ? THEN ? ELSE activation_date END, + deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END, + updated_by_username = ?, updated_at = ? + WHERE id = 1 + """, bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType, + parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country, + regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic, + addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress, + communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail, + website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions, + authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail, + turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(), + Timestamp.from(Instant.now())); + + return get(); + } + + private void ensureRowExists() { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1", + Integer.class); + if (count == null || count == 0) { + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + INSERT INTO m_institution_profile (id, status, created_by_username, created_at) + VALUES (1, 'ACTIVE', ?, ?) + """, currentUser.getUsername(), Timestamp.from(Instant.now())); + } + } + + /** The tenant's one enabled currency (see the INR-only enforcement migration) — read-only here. */ + private String resolveCurrencyCode() { + final List codes = jdbcTemplate.queryForList( + "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); + return codes.isEmpty() ? null : codes.get(0); + } + + private LocalDate parseDate(final String raw) { + return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null; + } + + private Date toSqlDate(final LocalDate d) { + return d == null ? null : Date.valueOf(d); + } + + private Map toResult(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("bankName", row.get("bank_name")); + out.put("legalName", row.get("legal_name")); + out.put("shortName", row.get("short_name")); + out.put("bankCode", row.get("bank_code")); + out.put("institutionCode", row.get("institution_code")); + out.put("bankType", row.get("bank_type")); + out.put("ownershipType", row.get("ownership_type")); + out.put("parentInstitutionName", row.get("parent_institution_name")); + out.put("registrationNumber", row.get("registration_number")); + out.put("licenseNumber", row.get("license_number")); + out.put("establishedDate", str(row.get("established_date"))); + out.put("status", row.get("status")); + out.put("country", row.get("country")); + out.put("currencyCode", resolveCurrencyCode()); + out.put("regulatoryAuthority", row.get("regulatory_authority")); + out.put("registrationDate", str(row.get("registration_date"))); + out.put("licenseStatus", row.get("license_status")); + out.put("licenseExpiryDate", str(row.get("license_expiry_date"))); + out.put("swiftBic", row.get("swift_bic")); + out.put("addressLine1", row.get("address_line1")); + out.put("addressLine2", row.get("address_line2")); + out.put("city", row.get("city")); + out.put("district", row.get("district")); + out.put("state", row.get("state")); + out.put("pincode", row.get("pincode")); + out.put("headOfficeSameAsRegistered", row.get("head_office_same_as_registered")); + out.put("headOfficeAddress", row.get("head_office_address")); + out.put("communicationSameAsRegistered", row.get("communication_same_as_registered")); + out.put("communicationAddress", row.get("communication_address")); + out.put("contactPhone", row.get("contact_phone")); + out.put("contactEmail", row.get("contact_email")); + out.put("customerCareNumber", row.get("customer_care_number")); + out.put("grievanceEmail", row.get("grievance_email")); + out.put("website", row.get("website")); + out.put("pan", row.get("pan")); + out.put("tan", row.get("tan")); + out.put("gstin", row.get("gstin")); + out.put("statementHeaderName", row.get("statement_header_name")); + out.put("passbookHeaderName", row.get("passbook_header_name")); + out.put("receiptHeaderName", row.get("receipt_header_name")); + out.put("termsAndConditions", row.get("terms_and_conditions")); + out.put("authorizedPersonName", row.get("authorized_person_name")); + out.put("authorizedPersonDesignation", row.get("authorized_person_designation")); + out.put("authorizedPersonContact", row.get("authorized_person_contact")); + out.put("authorizedPersonEmail", row.get("authorized_person_email")); + out.put("createdByUsername", row.get("created_by_username")); + out.put("createdAt", str(row.get("created_at"))); + out.put("activationDate", str(row.get("activation_date"))); + out.put("deactivationDate", str(row.get("deactivation_date"))); + out.put("updatedByUsername", row.get("updated_by_username")); + out.put("updatedAt", str(row.get("updated_at"))); + return out; + } + + private static String str(final Object o) { + return o == null ? null : o.toString(); + } +} 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 fceb9115a..b238153fb 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 @@ -33,4 +33,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml new file mode 100644 index 000000000..4f9137cad --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml new file mode 100644 index 000000000..a6097ff92 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_INSTITUTIONPROFILE' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_INSTITUTIONPROFILE', 'UPDATE_INSTITUTIONPROFILE', + 'READ_BRANCHPROFILE', 'UPDATE_BRANCHPROFILE', + 'READ_CLEARINGGLCONFIG', 'UPDATE_CLEARINGGLCONFIG') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml new file mode 100644 index 000000000..192a45e63 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml new file mode 100644 index 000000000..c3d46d6e4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml new file mode 100644 index 000000000..1ed5e4267 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml @@ -0,0 +1,28 @@ + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'READ_CLIENTNOMINEE' + AND r.name IN ('CASHIER', 'Clerk', 'Teller', 'Branch Manager', 'Principal Officer', 'As Manager') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + +